kiln_ai.adapters.model_adapters.litellm_adapter

   1import asyncio
   2import copy
   3import json
   4import logging
   5import time
   6from dataclasses import dataclass
   7from typing import Any, Dict, List, Tuple
   8
   9import litellm
  10from litellm.types.utils import (
  11    ChatCompletionMessageToolCall,
  12    ChoiceLogprobs,
  13    Choices,
  14    ModelResponse,
  15)
  16from litellm.types.utils import Message as LiteLLMMessage
  17from litellm.types.utils import Usage as LiteLlmUsage
  18from openai.types.chat.chat_completion_message_tool_call_param import (
  19    ChatCompletionMessageToolCallParam,
  20)
  21
  22import kiln_ai.datamodel as datamodel
  23from kiln_ai.adapters.chat import ChatCompletionMessageIncludingLiteLLM
  24from kiln_ai.adapters.chat.chat_formatter import chat_message_to_dict
  25from kiln_ai.adapters.ml_model_list import (
  26    KilnModelProvider,
  27    ModelProviderName,
  28    StructuredOutputMode,
  29)
  30from kiln_ai.adapters.model_adapters.adapter_stream import (
  31    EMPTY_RESPONSE_ERROR_MESSAGE,
  32    AdapterStream,
  33    raise_for_empty_model_response,
  34)
  35from kiln_ai.adapters.model_adapters.base_adapter import (
  36    AdapterConfig,
  37    BaseAdapter,
  38    MessageUsage,
  39    RunOutput,
  40    Usage,
  41)
  42from kiln_ai.adapters.model_adapters.litellm_config import LiteLlmConfig
  43from kiln_ai.datamodel.datamodel_enums import InputType
  44from kiln_ai.datamodel.json_schema import (
  45    close_object_schemas,
  46    strip_numeric_bounds,
  47    validate_schema_with_value_error,
  48)
  49from kiln_ai.datamodel.run_config import (
  50    KilnAgentRunConfigProperties,
  51    as_kiln_agent_run_config,
  52)
  53from kiln_ai.tools.base_tool import (
  54    KilnToolInterface,
  55    ToolCallContext,
  56    ToolCallDefinition,
  57)
  58from kiln_ai.tools.kiln_task_tool import KilnTaskToolResult
  59from kiln_ai.utils.exhaustive_error import raise_exhaustive_enum_error
  60from kiln_ai.utils.litellm import get_litellm_provider_info
  61from kiln_ai.utils.open_ai_types import (
  62    ChatCompletionAssistantMessageParamWrapper,
  63    ChatCompletionMessageParam,
  64    ChatCompletionToolMessageParamWrapper,
  65    sanitize_messages_for_provider,
  66)
  67
  68MAX_CALLS_PER_TURN = 10
  69MAX_TOOL_CALLS_PER_TURN = 30
  70
  71logger = logging.getLogger(__name__)
  72
  73
  74def _validate_unmanaged_tools(tools: list[KilnToolInterface]) -> None:
  75    for i, tool in enumerate(tools):
  76        if not isinstance(tool, KilnToolInterface):
  77            raise TypeError(
  78                f"unmanaged_tools[{i}] must be a KilnToolInterface instance, got {type(tool).__name__}"
  79            )
  80
  81
  82@dataclass
  83class ModelTurnResult:
  84    assistant_message: str
  85    all_messages: list[ChatCompletionMessageIncludingLiteLLM]
  86    model_response: ModelResponse | None
  87    model_choice: Choices | None
  88    usage: Usage
  89    interrupted_by_tool_calls: list[ChatCompletionMessageToolCall] | None = None
  90    message_latency: dict[int, int] | None = None
  91    message_usage: dict[int, MessageUsage] | None = None
  92
  93
  94class LiteLlmAdapter(BaseAdapter):
  95    def __init__(
  96        self,
  97        config: LiteLlmConfig,
  98        kiln_task: datamodel.Task,
  99        base_adapter_config: AdapterConfig | None = None,
 100    ):
 101        if not isinstance(config.run_config_properties, KilnAgentRunConfigProperties):
 102            raise ValueError("LiteLlmAdapter requires KilnAgentRunConfigProperties")
 103        self.config = config
 104        self._additional_body_options = config.additional_body_options
 105        self._api_base = config.base_url
 106        self._headers = config.default_headers
 107        self._litellm_model_id: str | None = None
 108        self._cached_available_tools: list[KilnToolInterface] | None = None
 109
 110        super().__init__(
 111            task=kiln_task,
 112            run_config=config.run_config_properties,
 113            config=base_adapter_config,
 114        )
 115
 116        unmanaged_tools = self.base_adapter_config.unmanaged_tools
 117        if unmanaged_tools:
 118            _validate_unmanaged_tools(unmanaged_tools)
 119
 120    async def _run_model_turn(
 121        self,
 122        provider: KilnModelProvider,
 123        messages: list[ChatCompletionMessageIncludingLiteLLM],
 124        top_logprobs: int | None,
 125        skip_response_format: bool,
 126    ) -> ModelTurnResult:
 127        """
 128        Call the model for a single top level turn: from user message to agent message.
 129
 130        It may make handle iterations of tool calls between the user/agent message if needed.
 131
 132        `messages` is the caller-owned list and is mutated in place (append/extend
 133        only — never rebound) so the partial trace survives any exception
 134        escaping this method.
 135        """
 136
 137        usage = Usage()
 138        tool_calls_count = 0
 139        # Per-LLM-call latency / usage, keyed by index in the messages list.
 140        # Kept separate because we don't own the LiteLLM message objects.
 141        message_latency: dict[int, int] = {}
 142        message_usage: dict[int, MessageUsage] = {}
 143
 144        while tool_calls_count < MAX_TOOL_CALLS_PER_TURN:
 145            # Build completion kwargs for tool calls
 146            completion_kwargs = await self.build_completion_kwargs(
 147                provider,
 148                # Pass a copy, as acompletion mutates objects and breaks types.
 149                copy.deepcopy(messages),
 150                top_logprobs,
 151                skip_response_format,
 152            )
 153
 154            # Make the completion call (timed)
 155            start = time.monotonic()
 156            model_response, response_choice = await self.acompletion_checking_response(
 157                **completion_kwargs
 158            )
 159            call_latency_ms = int((time.monotonic() - start) * 1000)
 160
 161            # count the usage
 162            call_usage = self.usage_from_response(model_response)
 163            usage += call_usage
 164            usage.total_llm_latency_ms = (
 165                usage.total_llm_latency_ms or 0
 166            ) + call_latency_ms
 167
 168            # Extract content and tool calls
 169            if not hasattr(response_choice, "message"):
 170                raise ValueError("Response choice has no message")
 171            content = response_choice.message.content
 172            tool_calls = response_choice.message.tool_calls
 173            if not content and not tool_calls:
 174                raise_for_empty_model_response(response_choice)
 175
 176            # Add message to messages, so it can be used in the next turn
 177            messages.append(response_choice.message)
 178            message_latency[len(messages) - 1] = call_latency_ms
 179            message_usage[len(messages) - 1] = call_usage
 180
 181            # Process tool calls if any
 182            if tool_calls and len(tool_calls) > 0:
 183                # check if we should return control to caller
 184                if self.base_adapter_config.return_on_tool_call:
 185                    # filter out task_response tool (task_response tools are internal)
 186                    standard_tool_calls = [
 187                        tc for tc in tool_calls if tc.function.name != "task_response"
 188                    ]
 189                    has_task_response = any(
 190                        tc.function.name == "task_response" for tc in tool_calls
 191                    )
 192                    if standard_tool_calls and not has_task_response:
 193                        return ModelTurnResult(
 194                            # we don't have any content, we are waiting for toolcall output to come back from client
 195                            assistant_message="",
 196                            all_messages=messages,
 197                            model_response=model_response,
 198                            model_choice=response_choice,
 199                            usage=usage,
 200                            interrupted_by_tool_calls=standard_tool_calls,
 201                            message_latency=message_latency,
 202                            message_usage=message_usage,
 203                        )
 204
 205                # otherwise: process tool calls internally until final output
 206                (
 207                    assistant_message_from_toolcall,
 208                    tool_call_messages,
 209                ) = await self.process_tool_calls(tool_calls)
 210
 211                # Add tool call results to messages
 212                messages.extend(tool_call_messages)
 213
 214                # If task_response tool was called, we're done
 215                if assistant_message_from_toolcall is not None:
 216                    return ModelTurnResult(
 217                        assistant_message=assistant_message_from_toolcall,
 218                        all_messages=messages,
 219                        model_response=model_response,
 220                        model_choice=response_choice,
 221                        usage=usage,
 222                        message_latency=message_latency,
 223                        message_usage=message_usage,
 224                    )
 225
 226                # If there were tool calls, increment counter and continue
 227                if tool_call_messages:
 228                    tool_calls_count += 1
 229                    continue
 230
 231            # If no tool calls, return the content as final output
 232            if content:
 233                return ModelTurnResult(
 234                    assistant_message=content,
 235                    all_messages=messages,
 236                    model_response=model_response,
 237                    model_choice=response_choice,
 238                    usage=usage,
 239                    message_latency=message_latency,
 240                    message_usage=message_usage,
 241                )
 242
 243            # If we get here with no content and no tool calls, break
 244            raise RuntimeError(
 245                "Model returned neither content nor tool calls. It must return at least one of these."
 246            )
 247
 248        raise RuntimeError(
 249            f"Too many tool calls ({tool_calls_count}). Stopping iteration to avoid using too many tokens."
 250        )
 251
 252    async def _run(
 253        self,
 254        input: InputType,
 255        trace_ref: list[ChatCompletionMessageParam],
 256        prior_trace: list[ChatCompletionMessageParam] | None = None,
 257    ) -> tuple[RunOutput, Usage | None]:
 258        usage = Usage()
 259
 260        provider = self.model_provider()
 261        if not provider.model_id:
 262            raise ValueError("Model ID is required for OpenAI compatible models")
 263
 264        # build_chat_formatter returns MultiturnFormatter when prior_trace is set, else prompt-based formatter
 265        chat_formatter = self.build_chat_formatter(input, prior_trace)
 266        # `trace_ref` is typed as `ChatCompletionMessageParam` for the
 267        # caller's API surface, but internally we store LiteLLM `Message`
 268        # objects transiently (converted by `all_messages_to_trace` before
 269        # export). We widen the type alias once here so the internal
 270        # mutations don't each need their own suppression.
 271        messages_internal: list[ChatCompletionMessageIncludingLiteLLM] = trace_ref  # type: ignore[assignment]
 272        messages_internal.extend(copy.deepcopy(chat_formatter.initial_messages()))
 273
 274        prior_output: str | None = None
 275        final_choice: Choices | None = None
 276        turns = 0
 277        message_latency: dict[int, int] = {}
 278        message_usage: dict[int, MessageUsage] = {}
 279
 280        # Same loop for both fresh runs and prior_trace continuation.
 281        # _run_model_turn has its own internal loop for tool calls (model calls tool -> we run it -> model continues).
 282        while True:
 283            turns += 1
 284            if turns > MAX_CALLS_PER_TURN:
 285                raise RuntimeError(
 286                    f"Too many turns ({turns}). Stopping iteration to avoid using too many tokens."
 287                )
 288
 289            turn = chat_formatter.next_turn(prior_output)
 290            if turn is None:
 291                # No next turn, we're done
 292                break
 293
 294            # Add messages from the turn to chat history
 295            for message in turn.messages:
 296                if message.content is None:
 297                    raise ValueError("Empty message content isn't allowed")
 298                messages_internal.append(chat_message_to_dict(message))  # type: ignore
 299
 300            skip_response_format = not turn.final_call
 301            turn_result = await self._run_model_turn(
 302                provider,
 303                messages_internal,
 304                self.base_adapter_config.top_logprobs if turn.final_call else None,
 305                skip_response_format,
 306            )
 307
 308            usage += turn_result.usage
 309            if turn_result.message_latency:
 310                message_latency.update(turn_result.message_latency)
 311            if turn_result.message_usage:
 312                message_usage.update(turn_result.message_usage)
 313
 314            prior_output = turn_result.assistant_message
 315            # Update messages_internal in place from turn_result so trace_ref
 316            # stays in sync (required by the base adapter error-wrapping contract).
 317            messages_internal[:] = turn_result.all_messages
 318            final_choice = turn_result.model_choice
 319
 320            # Check if we were interrupted by tool calls
 321            if turn_result.interrupted_by_tool_calls:
 322                trace = self.all_messages_to_trace(
 323                    messages_internal, message_latency, message_usage
 324                )
 325                intermediate_outputs = chat_formatter.intermediate_outputs()
 326                output = RunOutput(
 327                    output=prior_output or "",
 328                    intermediate_outputs=intermediate_outputs,
 329                    output_logprobs=None,
 330                    trace=trace,
 331                )
 332                return output, usage
 333
 334            if not prior_output:
 335                raise RuntimeError("No assistant message/output returned from model")
 336
 337        logprobs = self._extract_and_validate_logprobs(final_choice)
 338
 339        # Save COT/reasoning if it exists. May be a message, or may be parsed by LiteLLM (or openrouter, or anyone upstream)
 340        intermediate_outputs = chat_formatter.intermediate_outputs()
 341        self._extract_reasoning_to_intermediate_outputs(
 342            final_choice, intermediate_outputs
 343        )
 344
 345        if not isinstance(prior_output, str):
 346            raise RuntimeError(f"assistant message is not a string: {prior_output}")
 347
 348        trace = self.all_messages_to_trace(
 349            messages_internal, message_latency, message_usage
 350        )
 351        output = RunOutput(
 352            output=prior_output,
 353            intermediate_outputs=intermediate_outputs,
 354            output_logprobs=logprobs,
 355            trace=trace,
 356        )
 357
 358        return output, usage
 359
 360    def _create_run_stream(
 361        self,
 362        input: InputType,
 363        prior_trace: list[ChatCompletionMessageParam] | None = None,
 364    ) -> AdapterStream:
 365        provider = self.model_provider()
 366        if not provider.model_id:
 367            raise ValueError("Model ID is required for OpenAI compatible models")
 368
 369        chat_formatter = self.build_chat_formatter(input, prior_trace)
 370        initial_messages: list[ChatCompletionMessageIncludingLiteLLM] = copy.deepcopy(
 371            chat_formatter.initial_messages()
 372        )
 373
 374        return AdapterStream(
 375            adapter=self,
 376            provider=provider,
 377            chat_formatter=chat_formatter,
 378            initial_messages=initial_messages,
 379            top_logprobs=self.base_adapter_config.top_logprobs,
 380        )
 381
 382    def _extract_and_validate_logprobs(
 383        self, final_choice: Choices | None
 384    ) -> ChoiceLogprobs | None:
 385        """
 386        Extract logprobs from the final choice and validate they exist if required.
 387        """
 388        logprobs = None
 389        if (
 390            final_choice is not None
 391            and hasattr(final_choice, "logprobs")
 392            and isinstance(final_choice.logprobs, ChoiceLogprobs)
 393        ):
 394            logprobs = final_choice.logprobs
 395
 396        # Check logprobs worked, if required
 397        if self.base_adapter_config.top_logprobs is not None and logprobs is None:
 398            raise RuntimeError("Logprobs were required, but no logprobs were returned.")
 399
 400        return logprobs
 401
 402    def _extract_reasoning_to_intermediate_outputs(
 403        self, final_choice: Choices | None, intermediate_outputs: Dict[str, Any]
 404    ) -> None:
 405        """Extract reasoning content from model choice and add to intermediate outputs if present."""
 406        if (
 407            final_choice is not None
 408            and hasattr(final_choice, "message")
 409            and hasattr(final_choice.message, "reasoning_content")
 410        ):
 411            reasoning_content = final_choice.message.reasoning_content
 412            if reasoning_content is not None:
 413                stripped_reasoning_content = reasoning_content.strip()
 414                if len(stripped_reasoning_content) > 0:
 415                    intermediate_outputs["reasoning"] = stripped_reasoning_content
 416
 417    async def acompletion_checking_response(
 418        self, **kwargs: Any
 419    ) -> Tuple[ModelResponse, Choices]:
 420        response = await litellm.acompletion(**kwargs)
 421
 422        if (
 423            not isinstance(response, ModelResponse)
 424            or not response.choices
 425            or len(response.choices) == 0
 426            or not isinstance(response.choices[0], Choices)
 427        ):
 428            raise RuntimeError(
 429                f"Expected ModelResponse with Choices, got {type(response)}."
 430            )
 431        return response, response.choices[0]
 432
 433    def adapter_name(self) -> str:
 434        return "kiln_openai_compatible_adapter"
 435
 436    async def response_format_options(self) -> dict[str, Any]:
 437        # Unstructured if task isn't structured
 438        if not self.has_structured_output():
 439            return {}
 440
 441        run_config = as_kiln_agent_run_config(self.run_config)
 442        structured_output_mode: StructuredOutputMode = run_config.structured_output_mode
 443
 444        match structured_output_mode:
 445            case StructuredOutputMode.json_mode:
 446                return {"response_format": {"type": "json_object"}}
 447            case StructuredOutputMode.json_schema:
 448                return self.json_schema_response_format()
 449            case StructuredOutputMode.function_calling_weak:
 450                return self.tool_call_params(strict=False)
 451            case StructuredOutputMode.function_calling:
 452                return self.tool_call_params(strict=True)
 453            case StructuredOutputMode.json_instructions:
 454                # JSON instructions dynamically injected in prompt, not the API response format. Do not ask for json_object (see option below).
 455                return {}
 456            case StructuredOutputMode.json_custom_instructions:
 457                # JSON instructions statically injected in system prompt, not the API response format. Do not ask for json_object (see option above).
 458                return {}
 459            case StructuredOutputMode.json_instruction_and_object:
 460                # We set response_format to json_object and also set json instructions in the prompt
 461                return {"response_format": {"type": "json_object"}}
 462            case StructuredOutputMode.default:
 463                provider_name = run_config.model_provider_name
 464                if provider_name == ModelProviderName.ollama:
 465                    # Ollama added json_schema to all models: https://ollama.com/blog/structured-outputs
 466                    return self.json_schema_response_format()
 467                elif provider_name == ModelProviderName.docker_model_runner:
 468                    # Docker Model Runner uses OpenAI-compatible API with JSON schema support
 469                    return self.json_schema_response_format()
 470                else:
 471                    # Default to function calling -- it's older than the other modes. Higher compatibility.
 472                    # Strict isn't widely supported yet, so we don't use it by default unless it's OpenAI.
 473                    strict = provider_name == ModelProviderName.openai
 474                    return self.tool_call_params(strict=strict)
 475            case StructuredOutputMode.unknown:
 476                # See above, but this case should never happen.
 477                raise ValueError("Structured output mode is unknown.")
 478            case _:
 479                raise_exhaustive_enum_error(structured_output_mode)  # type: ignore[arg-type]
 480
 481    def json_schema_response_format(self) -> dict[str, Any]:
 482        output_schema = self.task.output_schema()
 483        if output_schema is None:
 484            raise ValueError(
 485                "Invalid output schema for this task. Cannot use JSON schema response format."
 486            )
 487        output_schema = close_object_schemas(output_schema, strict=True)
 488        # Strip numeric bounds (min/max/etc.) from integer/number nodes for the
 489        # json_schema wire format. Some providers (e.g. Claude via OpenRouter,
 490        # which maps onto Anthropic's newer output_config.format.schema API)
 491        # reject numeric bounds on integer/number types and return HTTP 400.
 492        # The valid ranges are still enforced by the prompt + post-hoc
 493        # validation, so this only affects the schema sent over the wire.
 494        output_schema = strip_numeric_bounds(output_schema)
 495        return {
 496            "response_format": {
 497                "type": "json_schema",
 498                "json_schema": {
 499                    "name": "task_response",
 500                    "schema": output_schema,
 501                },
 502            }
 503        }
 504
 505    def tool_call_params(self, strict: bool) -> dict[str, Any]:
 506        # Add additional_properties: false to the schema (OpenAI requires this for some models)
 507        output_schema = self.task.output_schema()
 508        if not isinstance(output_schema, dict):
 509            raise ValueError(
 510                "Invalid output schema for this task. Can not use tool calls."
 511            )
 512        output_schema = close_object_schemas(output_schema, strict=strict)
 513
 514        function_params = {
 515            "name": "task_response",
 516            "parameters": output_schema,
 517        }
 518        # This should be on, but we allow setting function_calling_weak for APIs that don't support it.
 519        if strict:
 520            function_params["strict"] = True
 521
 522        return {
 523            "tools": [
 524                {
 525                    "type": "function",
 526                    "function": function_params,
 527                }
 528            ],
 529            "tool_choice": {
 530                "type": "function",
 531                "function": {"name": "task_response"},
 532            },
 533        }
 534
 535    def build_extra_body(self, provider: KilnModelProvider) -> dict[str, Any]:
 536        # Don't love having this logic here. But it's worth the usability improvement
 537        # so better to keep it than exclude it. Should figure out how I want to isolate
 538        # this sort of logic so it's config driven and can be overridden
 539        extra_body: dict[str, Any] = {}
 540        provider_options = {}
 541
 542        run_config = as_kiln_agent_run_config(self.run_config)
 543        # For legacy config 'thinking_level' is not set, default to provider's default
 544        if "thinking_level" in run_config.model_fields_set:
 545            thinking_level = run_config.thinking_level
 546        else:
 547            thinking_level = provider.default_thinking_level
 548
 549        # Skip if provider doesn't support thinking levels (stale configs may still have one set)
 550        if (
 551            thinking_level is not None
 552            and provider.available_thinking_levels is not None
 553        ):
 554            # Anthropic models in OpenRouter uses reasoning object. See https://openrouter.ai/docs/use-cases/reasoning-tokens
 555            if (
 556                provider.name == ModelProviderName.openrouter
 557                and provider.openrouter_reasoning_object
 558            ):
 559                extra_body["reasoning"] = {"effort": thinking_level}
 560            elif (
 561                provider.name == ModelProviderName.anthropic
 562                and thinking_level == "none"
 563            ):
 564                # Anthropic's native API has no reasoning_effort="none"; passing it makes
 565                # litellm map thinking to None and then crash. Omitting reasoning_effort
 566                # disables extended thinking, which also frees temperature from the
 567                # temperature=1 requirement that applies whenever thinking is enabled.
 568                pass
 569            else:
 570                extra_body["reasoning_effort"] = thinking_level
 571                # Opus 4.7/4.8 default thinking display to "omitted", returning empty
 572                # thinking text. Request the summary so reasoning is surfaced. litellm
 573                # still maps reasoning_effort to output_config.effort; this only adds the
 574                # display to the adaptive thinking object.
 575                if (
 576                    provider.name == ModelProviderName.anthropic
 577                    and provider.anthropic_summarized_thinking
 578                ):
 579                    extra_body["thinking"] = {
 580                        "type": "adaptive",
 581                        "display": "summarized",
 582                    }
 583
 584        if provider.require_openrouter_reasoning:
 585            # https://openrouter.ai/docs/use-cases/reasoning-tokens
 586            extra_body["reasoning"] = {
 587                "exclude": False,
 588            }
 589
 590        if provider.gemini_reasoning_enabled:
 591            extra_body["reasoning"] = {
 592                "enabled": True,
 593            }
 594
 595        if provider.name == ModelProviderName.openrouter:
 596            # Ask OpenRouter to include usage in the response (cost)
 597            extra_body["usage"] = {"include": True}
 598
 599            # Set a default provider order for more deterministic routing.
 600            # OpenRouter will ignore providers that don't support the model.
 601            # Special cases below (like R1) can override this order.
 602            # allow_fallbacks is true by default, but we can override it here.
 603            provider_options["order"] = [
 604                "fireworks",
 605                "parasail",
 606                "together",
 607                "deepinfra",
 608                "novita",
 609                "groq",
 610                "amazon-bedrock",
 611                "azure",
 612                "nebius",
 613            ]
 614
 615        if provider.anthropic_extended_thinking and "thinking" not in extra_body:
 616            extra_body["thinking"] = {"type": "enabled", "budget_tokens": 4000}
 617
 618        if provider.r1_openrouter_options:
 619            # Require providers that support the reasoning parameter
 620            provider_options["require_parameters"] = True
 621            # Prefer R1 providers with reasonable perf/quants
 622            provider_options["order"] = ["fireworks", "together"]
 623            # R1 providers with unreasonable quants
 624            provider_options["ignore"] = ["deepinfra"]
 625
 626        # Only set of this request is to get logprobs.
 627        if (
 628            provider.logprobs_openrouter_options
 629            and self.base_adapter_config.top_logprobs is not None
 630        ):
 631            # Don't let OpenRouter choose a provider that doesn't support logprobs.
 632            provider_options["require_parameters"] = True
 633            # DeepInfra silently fails to return logprobs consistently.
 634            provider_options["ignore"] = ["deepinfra"]
 635
 636        if provider.openrouter_skip_required_parameters:
 637            # Oddball case, R1 14/8/1.5B fail with this param, even though they support thinking params.
 638            provider_options["require_parameters"] = False
 639
 640        # Siliconflow uses a bool flag for thinking, for some models
 641        if provider.siliconflow_enable_thinking is not None:
 642            extra_body["enable_thinking"] = provider.siliconflow_enable_thinking
 643
 644        if len(provider_options) > 0:
 645            extra_body["provider"] = provider_options
 646
 647        return extra_body
 648
 649    def litellm_model_id(self) -> str:
 650        # The model ID is an interesting combination of format and url endpoint.
 651        # It specifics the provider URL/host, but this is overridden if you manually set an api url
 652        if self._litellm_model_id:
 653            return self._litellm_model_id
 654
 655        litellm_provider_info = get_litellm_provider_info(self.model_provider())
 656        if litellm_provider_info.is_custom and self._api_base is None:
 657            raise ValueError(
 658                "Explicit Base URL is required for OpenAI compatible APIs (custom models, ollama, fine tunes, and custom registry models)"
 659            )
 660
 661        self._litellm_model_id = litellm_provider_info.litellm_model_id
 662        return self._litellm_model_id
 663
 664    def _allowed_openai_params_for_completion_kwargs(
 665        self, completion_kwargs: dict[str, Any]
 666    ) -> list[str]:
 667        """
 668        LiteLLM drops params it thinks are not supported by the model when drop_params is True. Sometimes it is wrong
 669        and we know it is supported, so we whitelist them here and pass that as an allowed_openai_params parameter.
 670        """
 671        # callers could have set allowed_openai_params in the additional_body_options, so we need to check for that
 672        explicit_allowed_params: Any | list = completion_kwargs.get(
 673            "allowed_openai_params", []
 674        )
 675        if not isinstance(explicit_allowed_params, list):
 676            raise ValueError(
 677                f"Unexpected allowed_openai_params format: {explicit_allowed_params} - expected list, got {type(explicit_allowed_params)}"
 678            )
 679        explicit_allowed_params_validated = [
 680            param for param in explicit_allowed_params if isinstance(param, str)
 681        ]
 682        invalid_count = len(explicit_allowed_params) - len(
 683            explicit_allowed_params_validated
 684        )
 685        if invalid_count > 0:
 686            raise ValueError(
 687                f"Unexpected allowed_openai_params format: {explicit_allowed_params} - {invalid_count} items are not strings"
 688            )
 689
 690        # these are our own logic
 691        automatic_allowed_params: list[str] = []
 692        if "tools" in completion_kwargs:
 693            automatic_allowed_params.append("tools")
 694        if "tool_choice" in completion_kwargs:
 695            automatic_allowed_params.append("tool_choice")
 696
 697        return list(set(explicit_allowed_params_validated + automatic_allowed_params))
 698
 699    async def build_completion_kwargs(
 700        self,
 701        provider: KilnModelProvider,
 702        messages: list[ChatCompletionMessageIncludingLiteLLM],
 703        top_logprobs: int | None,
 704        skip_response_format: bool = False,
 705    ) -> dict[str, Any]:
 706        run_config = as_kiln_agent_run_config(self.run_config)
 707        extra_body = self.build_extra_body(provider)
 708
 709        # Merge all parameters into a single kwargs dict for litellm
 710        completion_kwargs = {
 711            "model": self.litellm_model_id(),
 712            "messages": messages,
 713            "api_base": self._api_base,
 714            "headers": self._headers,
 715            "temperature": run_config.temperature,
 716            "top_p": run_config.top_p,
 717            # This drops params that are not supported by the model. Only openai params like top_p, temperature -- not litellm params like model, etc.
 718            # Not all models and providers support all openai params (for example, o3 doesn't support top_p)
 719            # Better to ignore them than to fail the model call.
 720            # https://docs.litellm.ai/docs/completion/input
 721            "drop_params": True,
 722            **extra_body,
 723            **self._additional_body_options,
 724        }
 725
 726        if self.base_adapter_config.automatic_prompt_caching:
 727            # Mark the last message for cache control. Litellm's AnthropicCacheControlHook
 728            # handles provider-specific injection. Providers auto-cache matching prefixes,
 729            # so marking the last message is sufficient for multi-turn conversations.
 730            completion_kwargs["cache_control_injection_points"] = [
 731                {"location": "message", "index": -1}
 732            ]
 733
 734        tool_calls = await self.litellm_tools()
 735        has_tools = len(tool_calls) > 0
 736        if has_tools:
 737            completion_kwargs["tools"] = tool_calls
 738            completion_kwargs["tool_choice"] = "auto"
 739
 740        # Special condition for Claude Opus 4.1 and Sonnet 4.5, where we can only specify top_p or temp, not both.
 741        # Remove default values (1.0) prioritizing anything the user customized, then error with helpful message if they are both custom.
 742        if provider.temp_top_p_exclusive:
 743            if "top_p" in completion_kwargs and completion_kwargs["top_p"] == 1.0:
 744                del completion_kwargs["top_p"]
 745            if (
 746                "temperature" in completion_kwargs
 747                and completion_kwargs["temperature"] == 1.0
 748            ):
 749                del completion_kwargs["temperature"]
 750            if "top_p" in completion_kwargs and "temperature" in completion_kwargs:
 751                raise ValueError(
 752                    "top_p and temperature can not both have custom values for this model. This is a restriction from the model provider. Please set only one of them to a custom value (not 1.0)."
 753                )
 754
 755        if not skip_response_format:
 756            # Response format: json_schema, json_instructions, json_mode, function_calling, etc
 757            response_format_options = await self.response_format_options()
 758
 759            # Check for a conflict between tools and response format using tools
 760            # We could reconsider this. Model could be able to choose between a final answer or a tool call on any turn. However, good models for tools tend to also support json_schea, so do we need to support both? If we do, merge them, and consider auto vs forced when merging (only forced for final, auto for merged).
 761            if has_tools and "tools" in response_format_options:
 762                raise ValueError(
 763                    "Function calling/tools can't be used as the JSON response format if you're also using tools. Please select a different structured output mode."
 764                )
 765
 766            completion_kwargs.update(response_format_options)
 767
 768        if top_logprobs is not None:
 769            completion_kwargs["logprobs"] = True
 770            completion_kwargs["top_logprobs"] = top_logprobs
 771
 772        # any params listed in this list will be passed to the model regardless of LiteLLM's own validation
 773        allowed_openai_params = self._allowed_openai_params_for_completion_kwargs(
 774            completion_kwargs
 775        )
 776        if len(allowed_openai_params) > 0:
 777            completion_kwargs["allowed_openai_params"] = allowed_openai_params
 778
 779        completion_kwargs["messages"] = sanitize_messages_for_provider(messages)
 780
 781        return completion_kwargs
 782
 783    def usage_from_response(self, response: ModelResponse) -> MessageUsage:
 784        litellm_usage = response.get("usage", None)
 785
 786        # LiteLLM isn't consistent in how it returns the cost.
 787        cost = response._hidden_params.get("response_cost", None)
 788        if cost is None and litellm_usage:
 789            cost = litellm_usage.get("cost", None)
 790
 791        usage = MessageUsage()
 792
 793        if not litellm_usage and not cost:
 794            return usage
 795
 796        if litellm_usage and isinstance(litellm_usage, LiteLlmUsage):
 797            usage.input_tokens = litellm_usage.get("prompt_tokens", None)
 798            usage.output_tokens = litellm_usage.get("completion_tokens", None)
 799            usage.total_tokens = litellm_usage.get("total_tokens", None)
 800            prompt_details = litellm_usage.get("prompt_tokens_details", None)
 801            if prompt_details and hasattr(prompt_details, "cached_tokens"):
 802                usage.cached_tokens = prompt_details.cached_tokens
 803            elif prompt_details:
 804                logger.warning(
 805                    f"prompt_tokens_details has unexpected type {type(prompt_details)}, cached_tokens not extracted"
 806                )
 807        else:
 808            logger.warning(
 809                f"Unexpected usage format from litellm: {litellm_usage}. Expected Usage object, got {type(litellm_usage)}"
 810            )
 811
 812        if isinstance(cost, float):
 813            usage.cost = cost
 814        elif cost is not None:
 815            # None is allowed, but no other types are expected
 816            logger.warning(
 817                f"Unexpected cost format from litellm: {cost}. Expected float, got {type(cost)}"
 818            )
 819
 820        return usage
 821
 822    async def cached_available_tools(self) -> list[KilnToolInterface]:
 823        if self._cached_available_tools is None:
 824            self._cached_available_tools = await self.available_tools()
 825        return self._cached_available_tools
 826
 827    async def _tools_for_execution(self) -> list[KilnToolInterface]:
 828        """Registry-resolved tools plus :attr:`AdapterConfig.unmanaged_tools` (same order as ``litellm_tools``)."""
 829        registry = await self.cached_available_tools()
 830        unmanaged = self.base_adapter_config.unmanaged_tools or []
 831        return registry + unmanaged
 832
 833    async def litellm_tools(self) -> list[ToolCallDefinition]:
 834        available_tools = await self.cached_available_tools()
 835
 836        registry_defs = [await tool.toolcall_definition() for tool in available_tools]
 837        unmanaged = self.base_adapter_config.unmanaged_tools
 838        unmanaged_defs = (
 839            [await t.toolcall_definition() for t in unmanaged] if unmanaged else []
 840        )
 841
 842        merged = registry_defs + unmanaged_defs
 843        seen_names: set[str] = set()
 844        for d in merged:
 845            name = d["function"]["name"]
 846            if name in seen_names:
 847                raise ValueError(
 848                    f"Duplicate tool name {name!r}: unmanaged and registry tools must have unique names."
 849                )
 850            seen_names.add(name)
 851
 852        return merged
 853
 854    async def process_tool_calls(
 855        self, tool_calls: list[ChatCompletionMessageToolCall] | None
 856    ) -> tuple[str | None, list[ChatCompletionToolMessageParamWrapper]]:
 857        if tool_calls is None:
 858            return None, []
 859
 860        assistant_output_from_toolcall: str | None = None
 861        tool_call_response_messages: list[ChatCompletionToolMessageParamWrapper] = []
 862        tool_run_coroutines = []
 863
 864        for tool_call in tool_calls:
 865            # Kiln "task_response" tool is used for returning structured output via tool calls.
 866            # Load the output from the tool call. Also
 867            if tool_call.function.name == "task_response":
 868                assistant_output_from_toolcall = tool_call.function.arguments
 869                continue
 870
 871            # Process normal tool calls (not the "task_response" tool)
 872            tool_name = tool_call.function.name
 873            tool = None
 874            for tool_option in await self._tools_for_execution():
 875                if await tool_option.name() == tool_name:
 876                    tool = tool_option
 877                    break
 878            if not tool:
 879                raise RuntimeError(
 880                    f"A tool named '{tool_name}' was invoked by a model, but was not available."
 881                )
 882
 883            # Parse the arguments and validate them against the tool's schema
 884            try:
 885                parsed_args = json.loads(tool_call.function.arguments)
 886            except json.JSONDecodeError:
 887                raise RuntimeError(
 888                    f"Failed to parse arguments for tool '{tool_name}' (should be JSON): {tool_call.function.arguments}"
 889                )
 890            try:
 891                tool_call_definition = await tool.toolcall_definition()
 892                json_schema = json.dumps(tool_call_definition["function"]["parameters"])
 893                validate_schema_with_value_error(parsed_args, json_schema)
 894            except Exception as e:
 895                raise RuntimeError(
 896                    f"Failed to validate arguments for tool '{tool_name}'. The arguments didn't match the tool's schema. The arguments were: {parsed_args}\n The error was: {e}"
 897                ) from e
 898
 899            # Create context with the calling task's allow_saving setting
 900            context = ToolCallContext(
 901                allow_saving=self.base_adapter_config.allow_saving
 902            )
 903
 904            async def run_tool_and_format(
 905                t=tool, c=context, args=parsed_args, tc_id=tool_call.id
 906            ):
 907                result = await t.run(c, **args)
 908                return ChatCompletionToolMessageParamWrapper(
 909                    role="tool",
 910                    tool_call_id=tc_id,
 911                    content=result.output,
 912                    kiln_task_tool_data=result.kiln_task_tool_data
 913                    if isinstance(result, KilnTaskToolResult)
 914                    else None,
 915                    is_error=result.is_error if result.is_error else None,
 916                    error_message=result.error_message
 917                    if result.error_message
 918                    else None,
 919                )
 920
 921            tool_run_coroutines.append(run_tool_and_format())
 922
 923        if tool_run_coroutines:
 924            tool_call_response_messages = await asyncio.gather(*tool_run_coroutines)
 925
 926        if (
 927            assistant_output_from_toolcall is not None
 928            and len(tool_call_response_messages) > 0
 929        ):
 930            raise RuntimeError(
 931                "Model asked for impossible combination: task_response tool call and other tool calls were both provided in the same turn. This is not supported as it means the model asked us to both return task_response results (ending the turn) and run new tools calls to send back to the model. If the model makes this mistake often, try a difference structured data model like JSON schema, where this is impossible."
 932            )
 933
 934        return assistant_output_from_toolcall, tool_call_response_messages
 935
 936    def litellm_message_to_trace_message(
 937        self,
 938        raw_message: LiteLLMMessage,
 939        latency_ms: int | None = None,
 940        usage: MessageUsage | None = None,
 941    ) -> ChatCompletionAssistantMessageParamWrapper:
 942        """
 943        Convert a LiteLLM Message object to an OpenAI compatible message, our ChatCompletionAssistantMessageParamWrapper
 944        """
 945        message: ChatCompletionAssistantMessageParamWrapper = {
 946            "role": "assistant",
 947        }
 948        if raw_message.role != "assistant":
 949            raise ValueError(
 950                "Model returned a message with a role other than assistant. This is not supported."
 951            )
 952
 953        if hasattr(raw_message, "content"):
 954            message["content"] = raw_message.content
 955        if hasattr(raw_message, "reasoning_content"):
 956            message["reasoning_content"] = raw_message.reasoning_content
 957        if hasattr(raw_message, "tool_calls"):
 958            # Convert ChatCompletionMessageToolCall to ChatCompletionMessageToolCallParam
 959            open_ai_tool_calls: List[ChatCompletionMessageToolCallParam] = []
 960            for litellm_tool_call in raw_message.tool_calls or []:
 961                # Optional in the SDK for streaming responses, but should never be None at this point.
 962                if litellm_tool_call.function.name is None:
 963                    raise ValueError(
 964                        "The model requested a tool call, without providing a function name (required)."
 965                    )
 966                open_ai_tool_calls.append(
 967                    ChatCompletionMessageToolCallParam(
 968                        id=litellm_tool_call.id,
 969                        type="function",
 970                        function={
 971                            "name": litellm_tool_call.function.name,
 972                            "arguments": litellm_tool_call.function.arguments,
 973                        },
 974                    )
 975                )
 976            if len(open_ai_tool_calls) > 0:
 977                message["tool_calls"] = open_ai_tool_calls
 978
 979        if latency_ms is not None:
 980            message["latency_ms"] = latency_ms
 981
 982        if usage is not None:
 983            message["usage"] = usage
 984
 985        if not message.get("content") and not message.get("tool_calls"):
 986            raise ValueError(EMPTY_RESPONSE_ERROR_MESSAGE)
 987
 988        return message
 989
 990    def all_messages_to_trace(
 991        self,
 992        messages: list[ChatCompletionMessageIncludingLiteLLM],
 993        message_latency: dict[int, int] | None = None,
 994        message_usage: dict[int, MessageUsage] | None = None,
 995    ) -> list[ChatCompletionMessageParam]:
 996        """
 997        Internally we allow LiteLLM Message objects, but for trace we need OpenAI compatible types. Replace LiteLLM Message objects with OpenAI compatible types.
 998
 999        Non-LiteLLM dict messages pass through unchanged. Any per-message
1000        ``usage``/``latency_ms`` already attached to those dicts (e.g. from a
1001        seeded prior trace) is preserved.
1002        """
1003        trace: list[ChatCompletionMessageParam] = []
1004        for i, message in enumerate(messages):
1005            if isinstance(message, LiteLLMMessage):
1006                latency_ms = message_latency.get(i) if message_latency else None
1007                usage = message_usage.get(i) if message_usage else None
1008                trace.append(
1009                    self.litellm_message_to_trace_message(message, latency_ms, usage)
1010                )
1011            else:
1012                trace.append(message)
1013        return trace
1014
1015    def _messages_to_trace(
1016        self,
1017        messages: list[ChatCompletionMessageParam],
1018    ) -> list[ChatCompletionMessageParam]:
1019        """Override: the `messages` list may transiently contain LiteLLM
1020        Message objects. Normalize to API-safe shapes for export on error.
1021        """
1022        return self.all_messages_to_trace(messages)  # type: ignore[arg-type]
MAX_CALLS_PER_TURN = 10
MAX_TOOL_CALLS_PER_TURN = 30
@dataclass
class ModelTurnResult:
83@dataclass
84class ModelTurnResult:
85    assistant_message: str
86    all_messages: list[ChatCompletionMessageIncludingLiteLLM]
87    model_response: ModelResponse | None
88    model_choice: Choices | None
89    usage: Usage
90    interrupted_by_tool_calls: list[ChatCompletionMessageToolCall] | None = None
91    message_latency: dict[int, int] | None = None
92    message_usage: dict[int, MessageUsage] | None = None
ModelTurnResult( assistant_message: str, all_messages: list[typing.Union[openai.types.chat.chat_completion_developer_message_param.ChatCompletionDeveloperMessageParam, openai.types.chat.chat_completion_system_message_param.ChatCompletionSystemMessageParam, openai.types.chat.chat_completion_user_message_param.ChatCompletionUserMessageParam, kiln_ai.utils.open_ai_types.ChatCompletionAssistantMessageParamWrapper, kiln_ai.utils.open_ai_types.ChatCompletionToolMessageParamWrapper, openai.types.chat.chat_completion_function_message_param.ChatCompletionFunctionMessageParam, litellm.types.utils.Message]], model_response: litellm.types.utils.ModelResponse | None, model_choice: litellm.types.utils.Choices | None, usage: kiln_ai.datamodel.Usage, interrupted_by_tool_calls: list[litellm.types.utils.ChatCompletionMessageToolCall] | None = None, message_latency: dict[int, int] | None = None, message_usage: dict[int, kiln_ai.datamodel.MessageUsage] | None = None)
assistant_message: str
all_messages: list[typing.Union[openai.types.chat.chat_completion_developer_message_param.ChatCompletionDeveloperMessageParam, openai.types.chat.chat_completion_system_message_param.ChatCompletionSystemMessageParam, openai.types.chat.chat_completion_user_message_param.ChatCompletionUserMessageParam, kiln_ai.utils.open_ai_types.ChatCompletionAssistantMessageParamWrapper, kiln_ai.utils.open_ai_types.ChatCompletionToolMessageParamWrapper, openai.types.chat.chat_completion_function_message_param.ChatCompletionFunctionMessageParam, litellm.types.utils.Message]]
model_response: litellm.types.utils.ModelResponse | None
model_choice: litellm.types.utils.Choices | None
interrupted_by_tool_calls: list[litellm.types.utils.ChatCompletionMessageToolCall] | None = None
message_latency: dict[int, int] | None = None
message_usage: dict[int, kiln_ai.datamodel.MessageUsage] | None = None
  95class LiteLlmAdapter(BaseAdapter):
  96    def __init__(
  97        self,
  98        config: LiteLlmConfig,
  99        kiln_task: datamodel.Task,
 100        base_adapter_config: AdapterConfig | None = None,
 101    ):
 102        if not isinstance(config.run_config_properties, KilnAgentRunConfigProperties):
 103            raise ValueError("LiteLlmAdapter requires KilnAgentRunConfigProperties")
 104        self.config = config
 105        self._additional_body_options = config.additional_body_options
 106        self._api_base = config.base_url
 107        self._headers = config.default_headers
 108        self._litellm_model_id: str | None = None
 109        self._cached_available_tools: list[KilnToolInterface] | None = None
 110
 111        super().__init__(
 112            task=kiln_task,
 113            run_config=config.run_config_properties,
 114            config=base_adapter_config,
 115        )
 116
 117        unmanaged_tools = self.base_adapter_config.unmanaged_tools
 118        if unmanaged_tools:
 119            _validate_unmanaged_tools(unmanaged_tools)
 120
 121    async def _run_model_turn(
 122        self,
 123        provider: KilnModelProvider,
 124        messages: list[ChatCompletionMessageIncludingLiteLLM],
 125        top_logprobs: int | None,
 126        skip_response_format: bool,
 127    ) -> ModelTurnResult:
 128        """
 129        Call the model for a single top level turn: from user message to agent message.
 130
 131        It may make handle iterations of tool calls between the user/agent message if needed.
 132
 133        `messages` is the caller-owned list and is mutated in place (append/extend
 134        only — never rebound) so the partial trace survives any exception
 135        escaping this method.
 136        """
 137
 138        usage = Usage()
 139        tool_calls_count = 0
 140        # Per-LLM-call latency / usage, keyed by index in the messages list.
 141        # Kept separate because we don't own the LiteLLM message objects.
 142        message_latency: dict[int, int] = {}
 143        message_usage: dict[int, MessageUsage] = {}
 144
 145        while tool_calls_count < MAX_TOOL_CALLS_PER_TURN:
 146            # Build completion kwargs for tool calls
 147            completion_kwargs = await self.build_completion_kwargs(
 148                provider,
 149                # Pass a copy, as acompletion mutates objects and breaks types.
 150                copy.deepcopy(messages),
 151                top_logprobs,
 152                skip_response_format,
 153            )
 154
 155            # Make the completion call (timed)
 156            start = time.monotonic()
 157            model_response, response_choice = await self.acompletion_checking_response(
 158                **completion_kwargs
 159            )
 160            call_latency_ms = int((time.monotonic() - start) * 1000)
 161
 162            # count the usage
 163            call_usage = self.usage_from_response(model_response)
 164            usage += call_usage
 165            usage.total_llm_latency_ms = (
 166                usage.total_llm_latency_ms or 0
 167            ) + call_latency_ms
 168
 169            # Extract content and tool calls
 170            if not hasattr(response_choice, "message"):
 171                raise ValueError("Response choice has no message")
 172            content = response_choice.message.content
 173            tool_calls = response_choice.message.tool_calls
 174            if not content and not tool_calls:
 175                raise_for_empty_model_response(response_choice)
 176
 177            # Add message to messages, so it can be used in the next turn
 178            messages.append(response_choice.message)
 179            message_latency[len(messages) - 1] = call_latency_ms
 180            message_usage[len(messages) - 1] = call_usage
 181
 182            # Process tool calls if any
 183            if tool_calls and len(tool_calls) > 0:
 184                # check if we should return control to caller
 185                if self.base_adapter_config.return_on_tool_call:
 186                    # filter out task_response tool (task_response tools are internal)
 187                    standard_tool_calls = [
 188                        tc for tc in tool_calls if tc.function.name != "task_response"
 189                    ]
 190                    has_task_response = any(
 191                        tc.function.name == "task_response" for tc in tool_calls
 192                    )
 193                    if standard_tool_calls and not has_task_response:
 194                        return ModelTurnResult(
 195                            # we don't have any content, we are waiting for toolcall output to come back from client
 196                            assistant_message="",
 197                            all_messages=messages,
 198                            model_response=model_response,
 199                            model_choice=response_choice,
 200                            usage=usage,
 201                            interrupted_by_tool_calls=standard_tool_calls,
 202                            message_latency=message_latency,
 203                            message_usage=message_usage,
 204                        )
 205
 206                # otherwise: process tool calls internally until final output
 207                (
 208                    assistant_message_from_toolcall,
 209                    tool_call_messages,
 210                ) = await self.process_tool_calls(tool_calls)
 211
 212                # Add tool call results to messages
 213                messages.extend(tool_call_messages)
 214
 215                # If task_response tool was called, we're done
 216                if assistant_message_from_toolcall is not None:
 217                    return ModelTurnResult(
 218                        assistant_message=assistant_message_from_toolcall,
 219                        all_messages=messages,
 220                        model_response=model_response,
 221                        model_choice=response_choice,
 222                        usage=usage,
 223                        message_latency=message_latency,
 224                        message_usage=message_usage,
 225                    )
 226
 227                # If there were tool calls, increment counter and continue
 228                if tool_call_messages:
 229                    tool_calls_count += 1
 230                    continue
 231
 232            # If no tool calls, return the content as final output
 233            if content:
 234                return ModelTurnResult(
 235                    assistant_message=content,
 236                    all_messages=messages,
 237                    model_response=model_response,
 238                    model_choice=response_choice,
 239                    usage=usage,
 240                    message_latency=message_latency,
 241                    message_usage=message_usage,
 242                )
 243
 244            # If we get here with no content and no tool calls, break
 245            raise RuntimeError(
 246                "Model returned neither content nor tool calls. It must return at least one of these."
 247            )
 248
 249        raise RuntimeError(
 250            f"Too many tool calls ({tool_calls_count}). Stopping iteration to avoid using too many tokens."
 251        )
 252
 253    async def _run(
 254        self,
 255        input: InputType,
 256        trace_ref: list[ChatCompletionMessageParam],
 257        prior_trace: list[ChatCompletionMessageParam] | None = None,
 258    ) -> tuple[RunOutput, Usage | None]:
 259        usage = Usage()
 260
 261        provider = self.model_provider()
 262        if not provider.model_id:
 263            raise ValueError("Model ID is required for OpenAI compatible models")
 264
 265        # build_chat_formatter returns MultiturnFormatter when prior_trace is set, else prompt-based formatter
 266        chat_formatter = self.build_chat_formatter(input, prior_trace)
 267        # `trace_ref` is typed as `ChatCompletionMessageParam` for the
 268        # caller's API surface, but internally we store LiteLLM `Message`
 269        # objects transiently (converted by `all_messages_to_trace` before
 270        # export). We widen the type alias once here so the internal
 271        # mutations don't each need their own suppression.
 272        messages_internal: list[ChatCompletionMessageIncludingLiteLLM] = trace_ref  # type: ignore[assignment]
 273        messages_internal.extend(copy.deepcopy(chat_formatter.initial_messages()))
 274
 275        prior_output: str | None = None
 276        final_choice: Choices | None = None
 277        turns = 0
 278        message_latency: dict[int, int] = {}
 279        message_usage: dict[int, MessageUsage] = {}
 280
 281        # Same loop for both fresh runs and prior_trace continuation.
 282        # _run_model_turn has its own internal loop for tool calls (model calls tool -> we run it -> model continues).
 283        while True:
 284            turns += 1
 285            if turns > MAX_CALLS_PER_TURN:
 286                raise RuntimeError(
 287                    f"Too many turns ({turns}). Stopping iteration to avoid using too many tokens."
 288                )
 289
 290            turn = chat_formatter.next_turn(prior_output)
 291            if turn is None:
 292                # No next turn, we're done
 293                break
 294
 295            # Add messages from the turn to chat history
 296            for message in turn.messages:
 297                if message.content is None:
 298                    raise ValueError("Empty message content isn't allowed")
 299                messages_internal.append(chat_message_to_dict(message))  # type: ignore
 300
 301            skip_response_format = not turn.final_call
 302            turn_result = await self._run_model_turn(
 303                provider,
 304                messages_internal,
 305                self.base_adapter_config.top_logprobs if turn.final_call else None,
 306                skip_response_format,
 307            )
 308
 309            usage += turn_result.usage
 310            if turn_result.message_latency:
 311                message_latency.update(turn_result.message_latency)
 312            if turn_result.message_usage:
 313                message_usage.update(turn_result.message_usage)
 314
 315            prior_output = turn_result.assistant_message
 316            # Update messages_internal in place from turn_result so trace_ref
 317            # stays in sync (required by the base adapter error-wrapping contract).
 318            messages_internal[:] = turn_result.all_messages
 319            final_choice = turn_result.model_choice
 320
 321            # Check if we were interrupted by tool calls
 322            if turn_result.interrupted_by_tool_calls:
 323                trace = self.all_messages_to_trace(
 324                    messages_internal, message_latency, message_usage
 325                )
 326                intermediate_outputs = chat_formatter.intermediate_outputs()
 327                output = RunOutput(
 328                    output=prior_output or "",
 329                    intermediate_outputs=intermediate_outputs,
 330                    output_logprobs=None,
 331                    trace=trace,
 332                )
 333                return output, usage
 334
 335            if not prior_output:
 336                raise RuntimeError("No assistant message/output returned from model")
 337
 338        logprobs = self._extract_and_validate_logprobs(final_choice)
 339
 340        # Save COT/reasoning if it exists. May be a message, or may be parsed by LiteLLM (or openrouter, or anyone upstream)
 341        intermediate_outputs = chat_formatter.intermediate_outputs()
 342        self._extract_reasoning_to_intermediate_outputs(
 343            final_choice, intermediate_outputs
 344        )
 345
 346        if not isinstance(prior_output, str):
 347            raise RuntimeError(f"assistant message is not a string: {prior_output}")
 348
 349        trace = self.all_messages_to_trace(
 350            messages_internal, message_latency, message_usage
 351        )
 352        output = RunOutput(
 353            output=prior_output,
 354            intermediate_outputs=intermediate_outputs,
 355            output_logprobs=logprobs,
 356            trace=trace,
 357        )
 358
 359        return output, usage
 360
 361    def _create_run_stream(
 362        self,
 363        input: InputType,
 364        prior_trace: list[ChatCompletionMessageParam] | None = None,
 365    ) -> AdapterStream:
 366        provider = self.model_provider()
 367        if not provider.model_id:
 368            raise ValueError("Model ID is required for OpenAI compatible models")
 369
 370        chat_formatter = self.build_chat_formatter(input, prior_trace)
 371        initial_messages: list[ChatCompletionMessageIncludingLiteLLM] = copy.deepcopy(
 372            chat_formatter.initial_messages()
 373        )
 374
 375        return AdapterStream(
 376            adapter=self,
 377            provider=provider,
 378            chat_formatter=chat_formatter,
 379            initial_messages=initial_messages,
 380            top_logprobs=self.base_adapter_config.top_logprobs,
 381        )
 382
 383    def _extract_and_validate_logprobs(
 384        self, final_choice: Choices | None
 385    ) -> ChoiceLogprobs | None:
 386        """
 387        Extract logprobs from the final choice and validate they exist if required.
 388        """
 389        logprobs = None
 390        if (
 391            final_choice is not None
 392            and hasattr(final_choice, "logprobs")
 393            and isinstance(final_choice.logprobs, ChoiceLogprobs)
 394        ):
 395            logprobs = final_choice.logprobs
 396
 397        # Check logprobs worked, if required
 398        if self.base_adapter_config.top_logprobs is not None and logprobs is None:
 399            raise RuntimeError("Logprobs were required, but no logprobs were returned.")
 400
 401        return logprobs
 402
 403    def _extract_reasoning_to_intermediate_outputs(
 404        self, final_choice: Choices | None, intermediate_outputs: Dict[str, Any]
 405    ) -> None:
 406        """Extract reasoning content from model choice and add to intermediate outputs if present."""
 407        if (
 408            final_choice is not None
 409            and hasattr(final_choice, "message")
 410            and hasattr(final_choice.message, "reasoning_content")
 411        ):
 412            reasoning_content = final_choice.message.reasoning_content
 413            if reasoning_content is not None:
 414                stripped_reasoning_content = reasoning_content.strip()
 415                if len(stripped_reasoning_content) > 0:
 416                    intermediate_outputs["reasoning"] = stripped_reasoning_content
 417
 418    async def acompletion_checking_response(
 419        self, **kwargs: Any
 420    ) -> Tuple[ModelResponse, Choices]:
 421        response = await litellm.acompletion(**kwargs)
 422
 423        if (
 424            not isinstance(response, ModelResponse)
 425            or not response.choices
 426            or len(response.choices) == 0
 427            or not isinstance(response.choices[0], Choices)
 428        ):
 429            raise RuntimeError(
 430                f"Expected ModelResponse with Choices, got {type(response)}."
 431            )
 432        return response, response.choices[0]
 433
 434    def adapter_name(self) -> str:
 435        return "kiln_openai_compatible_adapter"
 436
 437    async def response_format_options(self) -> dict[str, Any]:
 438        # Unstructured if task isn't structured
 439        if not self.has_structured_output():
 440            return {}
 441
 442        run_config = as_kiln_agent_run_config(self.run_config)
 443        structured_output_mode: StructuredOutputMode = run_config.structured_output_mode
 444
 445        match structured_output_mode:
 446            case StructuredOutputMode.json_mode:
 447                return {"response_format": {"type": "json_object"}}
 448            case StructuredOutputMode.json_schema:
 449                return self.json_schema_response_format()
 450            case StructuredOutputMode.function_calling_weak:
 451                return self.tool_call_params(strict=False)
 452            case StructuredOutputMode.function_calling:
 453                return self.tool_call_params(strict=True)
 454            case StructuredOutputMode.json_instructions:
 455                # JSON instructions dynamically injected in prompt, not the API response format. Do not ask for json_object (see option below).
 456                return {}
 457            case StructuredOutputMode.json_custom_instructions:
 458                # JSON instructions statically injected in system prompt, not the API response format. Do not ask for json_object (see option above).
 459                return {}
 460            case StructuredOutputMode.json_instruction_and_object:
 461                # We set response_format to json_object and also set json instructions in the prompt
 462                return {"response_format": {"type": "json_object"}}
 463            case StructuredOutputMode.default:
 464                provider_name = run_config.model_provider_name
 465                if provider_name == ModelProviderName.ollama:
 466                    # Ollama added json_schema to all models: https://ollama.com/blog/structured-outputs
 467                    return self.json_schema_response_format()
 468                elif provider_name == ModelProviderName.docker_model_runner:
 469                    # Docker Model Runner uses OpenAI-compatible API with JSON schema support
 470                    return self.json_schema_response_format()
 471                else:
 472                    # Default to function calling -- it's older than the other modes. Higher compatibility.
 473                    # Strict isn't widely supported yet, so we don't use it by default unless it's OpenAI.
 474                    strict = provider_name == ModelProviderName.openai
 475                    return self.tool_call_params(strict=strict)
 476            case StructuredOutputMode.unknown:
 477                # See above, but this case should never happen.
 478                raise ValueError("Structured output mode is unknown.")
 479            case _:
 480                raise_exhaustive_enum_error(structured_output_mode)  # type: ignore[arg-type]
 481
 482    def json_schema_response_format(self) -> dict[str, Any]:
 483        output_schema = self.task.output_schema()
 484        if output_schema is None:
 485            raise ValueError(
 486                "Invalid output schema for this task. Cannot use JSON schema response format."
 487            )
 488        output_schema = close_object_schemas(output_schema, strict=True)
 489        # Strip numeric bounds (min/max/etc.) from integer/number nodes for the
 490        # json_schema wire format. Some providers (e.g. Claude via OpenRouter,
 491        # which maps onto Anthropic's newer output_config.format.schema API)
 492        # reject numeric bounds on integer/number types and return HTTP 400.
 493        # The valid ranges are still enforced by the prompt + post-hoc
 494        # validation, so this only affects the schema sent over the wire.
 495        output_schema = strip_numeric_bounds(output_schema)
 496        return {
 497            "response_format": {
 498                "type": "json_schema",
 499                "json_schema": {
 500                    "name": "task_response",
 501                    "schema": output_schema,
 502                },
 503            }
 504        }
 505
 506    def tool_call_params(self, strict: bool) -> dict[str, Any]:
 507        # Add additional_properties: false to the schema (OpenAI requires this for some models)
 508        output_schema = self.task.output_schema()
 509        if not isinstance(output_schema, dict):
 510            raise ValueError(
 511                "Invalid output schema for this task. Can not use tool calls."
 512            )
 513        output_schema = close_object_schemas(output_schema, strict=strict)
 514
 515        function_params = {
 516            "name": "task_response",
 517            "parameters": output_schema,
 518        }
 519        # This should be on, but we allow setting function_calling_weak for APIs that don't support it.
 520        if strict:
 521            function_params["strict"] = True
 522
 523        return {
 524            "tools": [
 525                {
 526                    "type": "function",
 527                    "function": function_params,
 528                }
 529            ],
 530            "tool_choice": {
 531                "type": "function",
 532                "function": {"name": "task_response"},
 533            },
 534        }
 535
 536    def build_extra_body(self, provider: KilnModelProvider) -> dict[str, Any]:
 537        # Don't love having this logic here. But it's worth the usability improvement
 538        # so better to keep it than exclude it. Should figure out how I want to isolate
 539        # this sort of logic so it's config driven and can be overridden
 540        extra_body: dict[str, Any] = {}
 541        provider_options = {}
 542
 543        run_config = as_kiln_agent_run_config(self.run_config)
 544        # For legacy config 'thinking_level' is not set, default to provider's default
 545        if "thinking_level" in run_config.model_fields_set:
 546            thinking_level = run_config.thinking_level
 547        else:
 548            thinking_level = provider.default_thinking_level
 549
 550        # Skip if provider doesn't support thinking levels (stale configs may still have one set)
 551        if (
 552            thinking_level is not None
 553            and provider.available_thinking_levels is not None
 554        ):
 555            # Anthropic models in OpenRouter uses reasoning object. See https://openrouter.ai/docs/use-cases/reasoning-tokens
 556            if (
 557                provider.name == ModelProviderName.openrouter
 558                and provider.openrouter_reasoning_object
 559            ):
 560                extra_body["reasoning"] = {"effort": thinking_level}
 561            elif (
 562                provider.name == ModelProviderName.anthropic
 563                and thinking_level == "none"
 564            ):
 565                # Anthropic's native API has no reasoning_effort="none"; passing it makes
 566                # litellm map thinking to None and then crash. Omitting reasoning_effort
 567                # disables extended thinking, which also frees temperature from the
 568                # temperature=1 requirement that applies whenever thinking is enabled.
 569                pass
 570            else:
 571                extra_body["reasoning_effort"] = thinking_level
 572                # Opus 4.7/4.8 default thinking display to "omitted", returning empty
 573                # thinking text. Request the summary so reasoning is surfaced. litellm
 574                # still maps reasoning_effort to output_config.effort; this only adds the
 575                # display to the adaptive thinking object.
 576                if (
 577                    provider.name == ModelProviderName.anthropic
 578                    and provider.anthropic_summarized_thinking
 579                ):
 580                    extra_body["thinking"] = {
 581                        "type": "adaptive",
 582                        "display": "summarized",
 583                    }
 584
 585        if provider.require_openrouter_reasoning:
 586            # https://openrouter.ai/docs/use-cases/reasoning-tokens
 587            extra_body["reasoning"] = {
 588                "exclude": False,
 589            }
 590
 591        if provider.gemini_reasoning_enabled:
 592            extra_body["reasoning"] = {
 593                "enabled": True,
 594            }
 595
 596        if provider.name == ModelProviderName.openrouter:
 597            # Ask OpenRouter to include usage in the response (cost)
 598            extra_body["usage"] = {"include": True}
 599
 600            # Set a default provider order for more deterministic routing.
 601            # OpenRouter will ignore providers that don't support the model.
 602            # Special cases below (like R1) can override this order.
 603            # allow_fallbacks is true by default, but we can override it here.
 604            provider_options["order"] = [
 605                "fireworks",
 606                "parasail",
 607                "together",
 608                "deepinfra",
 609                "novita",
 610                "groq",
 611                "amazon-bedrock",
 612                "azure",
 613                "nebius",
 614            ]
 615
 616        if provider.anthropic_extended_thinking and "thinking" not in extra_body:
 617            extra_body["thinking"] = {"type": "enabled", "budget_tokens": 4000}
 618
 619        if provider.r1_openrouter_options:
 620            # Require providers that support the reasoning parameter
 621            provider_options["require_parameters"] = True
 622            # Prefer R1 providers with reasonable perf/quants
 623            provider_options["order"] = ["fireworks", "together"]
 624            # R1 providers with unreasonable quants
 625            provider_options["ignore"] = ["deepinfra"]
 626
 627        # Only set of this request is to get logprobs.
 628        if (
 629            provider.logprobs_openrouter_options
 630            and self.base_adapter_config.top_logprobs is not None
 631        ):
 632            # Don't let OpenRouter choose a provider that doesn't support logprobs.
 633            provider_options["require_parameters"] = True
 634            # DeepInfra silently fails to return logprobs consistently.
 635            provider_options["ignore"] = ["deepinfra"]
 636
 637        if provider.openrouter_skip_required_parameters:
 638            # Oddball case, R1 14/8/1.5B fail with this param, even though they support thinking params.
 639            provider_options["require_parameters"] = False
 640
 641        # Siliconflow uses a bool flag for thinking, for some models
 642        if provider.siliconflow_enable_thinking is not None:
 643            extra_body["enable_thinking"] = provider.siliconflow_enable_thinking
 644
 645        if len(provider_options) > 0:
 646            extra_body["provider"] = provider_options
 647
 648        return extra_body
 649
 650    def litellm_model_id(self) -> str:
 651        # The model ID is an interesting combination of format and url endpoint.
 652        # It specifics the provider URL/host, but this is overridden if you manually set an api url
 653        if self._litellm_model_id:
 654            return self._litellm_model_id
 655
 656        litellm_provider_info = get_litellm_provider_info(self.model_provider())
 657        if litellm_provider_info.is_custom and self._api_base is None:
 658            raise ValueError(
 659                "Explicit Base URL is required for OpenAI compatible APIs (custom models, ollama, fine tunes, and custom registry models)"
 660            )
 661
 662        self._litellm_model_id = litellm_provider_info.litellm_model_id
 663        return self._litellm_model_id
 664
 665    def _allowed_openai_params_for_completion_kwargs(
 666        self, completion_kwargs: dict[str, Any]
 667    ) -> list[str]:
 668        """
 669        LiteLLM drops params it thinks are not supported by the model when drop_params is True. Sometimes it is wrong
 670        and we know it is supported, so we whitelist them here and pass that as an allowed_openai_params parameter.
 671        """
 672        # callers could have set allowed_openai_params in the additional_body_options, so we need to check for that
 673        explicit_allowed_params: Any | list = completion_kwargs.get(
 674            "allowed_openai_params", []
 675        )
 676        if not isinstance(explicit_allowed_params, list):
 677            raise ValueError(
 678                f"Unexpected allowed_openai_params format: {explicit_allowed_params} - expected list, got {type(explicit_allowed_params)}"
 679            )
 680        explicit_allowed_params_validated = [
 681            param for param in explicit_allowed_params if isinstance(param, str)
 682        ]
 683        invalid_count = len(explicit_allowed_params) - len(
 684            explicit_allowed_params_validated
 685        )
 686        if invalid_count > 0:
 687            raise ValueError(
 688                f"Unexpected allowed_openai_params format: {explicit_allowed_params} - {invalid_count} items are not strings"
 689            )
 690
 691        # these are our own logic
 692        automatic_allowed_params: list[str] = []
 693        if "tools" in completion_kwargs:
 694            automatic_allowed_params.append("tools")
 695        if "tool_choice" in completion_kwargs:
 696            automatic_allowed_params.append("tool_choice")
 697
 698        return list(set(explicit_allowed_params_validated + automatic_allowed_params))
 699
 700    async def build_completion_kwargs(
 701        self,
 702        provider: KilnModelProvider,
 703        messages: list[ChatCompletionMessageIncludingLiteLLM],
 704        top_logprobs: int | None,
 705        skip_response_format: bool = False,
 706    ) -> dict[str, Any]:
 707        run_config = as_kiln_agent_run_config(self.run_config)
 708        extra_body = self.build_extra_body(provider)
 709
 710        # Merge all parameters into a single kwargs dict for litellm
 711        completion_kwargs = {
 712            "model": self.litellm_model_id(),
 713            "messages": messages,
 714            "api_base": self._api_base,
 715            "headers": self._headers,
 716            "temperature": run_config.temperature,
 717            "top_p": run_config.top_p,
 718            # This drops params that are not supported by the model. Only openai params like top_p, temperature -- not litellm params like model, etc.
 719            # Not all models and providers support all openai params (for example, o3 doesn't support top_p)
 720            # Better to ignore them than to fail the model call.
 721            # https://docs.litellm.ai/docs/completion/input
 722            "drop_params": True,
 723            **extra_body,
 724            **self._additional_body_options,
 725        }
 726
 727        if self.base_adapter_config.automatic_prompt_caching:
 728            # Mark the last message for cache control. Litellm's AnthropicCacheControlHook
 729            # handles provider-specific injection. Providers auto-cache matching prefixes,
 730            # so marking the last message is sufficient for multi-turn conversations.
 731            completion_kwargs["cache_control_injection_points"] = [
 732                {"location": "message", "index": -1}
 733            ]
 734
 735        tool_calls = await self.litellm_tools()
 736        has_tools = len(tool_calls) > 0
 737        if has_tools:
 738            completion_kwargs["tools"] = tool_calls
 739            completion_kwargs["tool_choice"] = "auto"
 740
 741        # Special condition for Claude Opus 4.1 and Sonnet 4.5, where we can only specify top_p or temp, not both.
 742        # Remove default values (1.0) prioritizing anything the user customized, then error with helpful message if they are both custom.
 743        if provider.temp_top_p_exclusive:
 744            if "top_p" in completion_kwargs and completion_kwargs["top_p"] == 1.0:
 745                del completion_kwargs["top_p"]
 746            if (
 747                "temperature" in completion_kwargs
 748                and completion_kwargs["temperature"] == 1.0
 749            ):
 750                del completion_kwargs["temperature"]
 751            if "top_p" in completion_kwargs and "temperature" in completion_kwargs:
 752                raise ValueError(
 753                    "top_p and temperature can not both have custom values for this model. This is a restriction from the model provider. Please set only one of them to a custom value (not 1.0)."
 754                )
 755
 756        if not skip_response_format:
 757            # Response format: json_schema, json_instructions, json_mode, function_calling, etc
 758            response_format_options = await self.response_format_options()
 759
 760            # Check for a conflict between tools and response format using tools
 761            # We could reconsider this. Model could be able to choose between a final answer or a tool call on any turn. However, good models for tools tend to also support json_schea, so do we need to support both? If we do, merge them, and consider auto vs forced when merging (only forced for final, auto for merged).
 762            if has_tools and "tools" in response_format_options:
 763                raise ValueError(
 764                    "Function calling/tools can't be used as the JSON response format if you're also using tools. Please select a different structured output mode."
 765                )
 766
 767            completion_kwargs.update(response_format_options)
 768
 769        if top_logprobs is not None:
 770            completion_kwargs["logprobs"] = True
 771            completion_kwargs["top_logprobs"] = top_logprobs
 772
 773        # any params listed in this list will be passed to the model regardless of LiteLLM's own validation
 774        allowed_openai_params = self._allowed_openai_params_for_completion_kwargs(
 775            completion_kwargs
 776        )
 777        if len(allowed_openai_params) > 0:
 778            completion_kwargs["allowed_openai_params"] = allowed_openai_params
 779
 780        completion_kwargs["messages"] = sanitize_messages_for_provider(messages)
 781
 782        return completion_kwargs
 783
 784    def usage_from_response(self, response: ModelResponse) -> MessageUsage:
 785        litellm_usage = response.get("usage", None)
 786
 787        # LiteLLM isn't consistent in how it returns the cost.
 788        cost = response._hidden_params.get("response_cost", None)
 789        if cost is None and litellm_usage:
 790            cost = litellm_usage.get("cost", None)
 791
 792        usage = MessageUsage()
 793
 794        if not litellm_usage and not cost:
 795            return usage
 796
 797        if litellm_usage and isinstance(litellm_usage, LiteLlmUsage):
 798            usage.input_tokens = litellm_usage.get("prompt_tokens", None)
 799            usage.output_tokens = litellm_usage.get("completion_tokens", None)
 800            usage.total_tokens = litellm_usage.get("total_tokens", None)
 801            prompt_details = litellm_usage.get("prompt_tokens_details", None)
 802            if prompt_details and hasattr(prompt_details, "cached_tokens"):
 803                usage.cached_tokens = prompt_details.cached_tokens
 804            elif prompt_details:
 805                logger.warning(
 806                    f"prompt_tokens_details has unexpected type {type(prompt_details)}, cached_tokens not extracted"
 807                )
 808        else:
 809            logger.warning(
 810                f"Unexpected usage format from litellm: {litellm_usage}. Expected Usage object, got {type(litellm_usage)}"
 811            )
 812
 813        if isinstance(cost, float):
 814            usage.cost = cost
 815        elif cost is not None:
 816            # None is allowed, but no other types are expected
 817            logger.warning(
 818                f"Unexpected cost format from litellm: {cost}. Expected float, got {type(cost)}"
 819            )
 820
 821        return usage
 822
 823    async def cached_available_tools(self) -> list[KilnToolInterface]:
 824        if self._cached_available_tools is None:
 825            self._cached_available_tools = await self.available_tools()
 826        return self._cached_available_tools
 827
 828    async def _tools_for_execution(self) -> list[KilnToolInterface]:
 829        """Registry-resolved tools plus :attr:`AdapterConfig.unmanaged_tools` (same order as ``litellm_tools``)."""
 830        registry = await self.cached_available_tools()
 831        unmanaged = self.base_adapter_config.unmanaged_tools or []
 832        return registry + unmanaged
 833
 834    async def litellm_tools(self) -> list[ToolCallDefinition]:
 835        available_tools = await self.cached_available_tools()
 836
 837        registry_defs = [await tool.toolcall_definition() for tool in available_tools]
 838        unmanaged = self.base_adapter_config.unmanaged_tools
 839        unmanaged_defs = (
 840            [await t.toolcall_definition() for t in unmanaged] if unmanaged else []
 841        )
 842
 843        merged = registry_defs + unmanaged_defs
 844        seen_names: set[str] = set()
 845        for d in merged:
 846            name = d["function"]["name"]
 847            if name in seen_names:
 848                raise ValueError(
 849                    f"Duplicate tool name {name!r}: unmanaged and registry tools must have unique names."
 850                )
 851            seen_names.add(name)
 852
 853        return merged
 854
 855    async def process_tool_calls(
 856        self, tool_calls: list[ChatCompletionMessageToolCall] | None
 857    ) -> tuple[str | None, list[ChatCompletionToolMessageParamWrapper]]:
 858        if tool_calls is None:
 859            return None, []
 860
 861        assistant_output_from_toolcall: str | None = None
 862        tool_call_response_messages: list[ChatCompletionToolMessageParamWrapper] = []
 863        tool_run_coroutines = []
 864
 865        for tool_call in tool_calls:
 866            # Kiln "task_response" tool is used for returning structured output via tool calls.
 867            # Load the output from the tool call. Also
 868            if tool_call.function.name == "task_response":
 869                assistant_output_from_toolcall = tool_call.function.arguments
 870                continue
 871
 872            # Process normal tool calls (not the "task_response" tool)
 873            tool_name = tool_call.function.name
 874            tool = None
 875            for tool_option in await self._tools_for_execution():
 876                if await tool_option.name() == tool_name:
 877                    tool = tool_option
 878                    break
 879            if not tool:
 880                raise RuntimeError(
 881                    f"A tool named '{tool_name}' was invoked by a model, but was not available."
 882                )
 883
 884            # Parse the arguments and validate them against the tool's schema
 885            try:
 886                parsed_args = json.loads(tool_call.function.arguments)
 887            except json.JSONDecodeError:
 888                raise RuntimeError(
 889                    f"Failed to parse arguments for tool '{tool_name}' (should be JSON): {tool_call.function.arguments}"
 890                )
 891            try:
 892                tool_call_definition = await tool.toolcall_definition()
 893                json_schema = json.dumps(tool_call_definition["function"]["parameters"])
 894                validate_schema_with_value_error(parsed_args, json_schema)
 895            except Exception as e:
 896                raise RuntimeError(
 897                    f"Failed to validate arguments for tool '{tool_name}'. The arguments didn't match the tool's schema. The arguments were: {parsed_args}\n The error was: {e}"
 898                ) from e
 899
 900            # Create context with the calling task's allow_saving setting
 901            context = ToolCallContext(
 902                allow_saving=self.base_adapter_config.allow_saving
 903            )
 904
 905            async def run_tool_and_format(
 906                t=tool, c=context, args=parsed_args, tc_id=tool_call.id
 907            ):
 908                result = await t.run(c, **args)
 909                return ChatCompletionToolMessageParamWrapper(
 910                    role="tool",
 911                    tool_call_id=tc_id,
 912                    content=result.output,
 913                    kiln_task_tool_data=result.kiln_task_tool_data
 914                    if isinstance(result, KilnTaskToolResult)
 915                    else None,
 916                    is_error=result.is_error if result.is_error else None,
 917                    error_message=result.error_message
 918                    if result.error_message
 919                    else None,
 920                )
 921
 922            tool_run_coroutines.append(run_tool_and_format())
 923
 924        if tool_run_coroutines:
 925            tool_call_response_messages = await asyncio.gather(*tool_run_coroutines)
 926
 927        if (
 928            assistant_output_from_toolcall is not None
 929            and len(tool_call_response_messages) > 0
 930        ):
 931            raise RuntimeError(
 932                "Model asked for impossible combination: task_response tool call and other tool calls were both provided in the same turn. This is not supported as it means the model asked us to both return task_response results (ending the turn) and run new tools calls to send back to the model. If the model makes this mistake often, try a difference structured data model like JSON schema, where this is impossible."
 933            )
 934
 935        return assistant_output_from_toolcall, tool_call_response_messages
 936
 937    def litellm_message_to_trace_message(
 938        self,
 939        raw_message: LiteLLMMessage,
 940        latency_ms: int | None = None,
 941        usage: MessageUsage | None = None,
 942    ) -> ChatCompletionAssistantMessageParamWrapper:
 943        """
 944        Convert a LiteLLM Message object to an OpenAI compatible message, our ChatCompletionAssistantMessageParamWrapper
 945        """
 946        message: ChatCompletionAssistantMessageParamWrapper = {
 947            "role": "assistant",
 948        }
 949        if raw_message.role != "assistant":
 950            raise ValueError(
 951                "Model returned a message with a role other than assistant. This is not supported."
 952            )
 953
 954        if hasattr(raw_message, "content"):
 955            message["content"] = raw_message.content
 956        if hasattr(raw_message, "reasoning_content"):
 957            message["reasoning_content"] = raw_message.reasoning_content
 958        if hasattr(raw_message, "tool_calls"):
 959            # Convert ChatCompletionMessageToolCall to ChatCompletionMessageToolCallParam
 960            open_ai_tool_calls: List[ChatCompletionMessageToolCallParam] = []
 961            for litellm_tool_call in raw_message.tool_calls or []:
 962                # Optional in the SDK for streaming responses, but should never be None at this point.
 963                if litellm_tool_call.function.name is None:
 964                    raise ValueError(
 965                        "The model requested a tool call, without providing a function name (required)."
 966                    )
 967                open_ai_tool_calls.append(
 968                    ChatCompletionMessageToolCallParam(
 969                        id=litellm_tool_call.id,
 970                        type="function",
 971                        function={
 972                            "name": litellm_tool_call.function.name,
 973                            "arguments": litellm_tool_call.function.arguments,
 974                        },
 975                    )
 976                )
 977            if len(open_ai_tool_calls) > 0:
 978                message["tool_calls"] = open_ai_tool_calls
 979
 980        if latency_ms is not None:
 981            message["latency_ms"] = latency_ms
 982
 983        if usage is not None:
 984            message["usage"] = usage
 985
 986        if not message.get("content") and not message.get("tool_calls"):
 987            raise ValueError(EMPTY_RESPONSE_ERROR_MESSAGE)
 988
 989        return message
 990
 991    def all_messages_to_trace(
 992        self,
 993        messages: list[ChatCompletionMessageIncludingLiteLLM],
 994        message_latency: dict[int, int] | None = None,
 995        message_usage: dict[int, MessageUsage] | None = None,
 996    ) -> list[ChatCompletionMessageParam]:
 997        """
 998        Internally we allow LiteLLM Message objects, but for trace we need OpenAI compatible types. Replace LiteLLM Message objects with OpenAI compatible types.
 999
1000        Non-LiteLLM dict messages pass through unchanged. Any per-message
1001        ``usage``/``latency_ms`` already attached to those dicts (e.g. from a
1002        seeded prior trace) is preserved.
1003        """
1004        trace: list[ChatCompletionMessageParam] = []
1005        for i, message in enumerate(messages):
1006            if isinstance(message, LiteLLMMessage):
1007                latency_ms = message_latency.get(i) if message_latency else None
1008                usage = message_usage.get(i) if message_usage else None
1009                trace.append(
1010                    self.litellm_message_to_trace_message(message, latency_ms, usage)
1011                )
1012            else:
1013                trace.append(message)
1014        return trace
1015
1016    def _messages_to_trace(
1017        self,
1018        messages: list[ChatCompletionMessageParam],
1019    ) -> list[ChatCompletionMessageParam]:
1020        """Override: the `messages` list may transiently contain LiteLLM
1021        Message objects. Normalize to API-safe shapes for export on error.
1022        """
1023        return self.all_messages_to_trace(messages)  # type: ignore[arg-type]

Base class for AI model adapters that handle task execution.

This abstract class provides the foundation for implementing model-specific adapters that can process tasks with structured or unstructured inputs/outputs. It handles input/output validation, prompt building, and run tracking.

Prompt building is handled internally by the adapter, which uses a prompt builder based on the run config. To override the prompt building behavior, pass a custom prompt builder to the adapter config.

LiteLlmAdapter( config: kiln_ai.adapters.model_adapters.litellm_config.LiteLlmConfig, kiln_task: kiln_ai.datamodel.Task, base_adapter_config: kiln_ai.adapters.model_adapters.base_adapter.AdapterConfig | None = None)
 96    def __init__(
 97        self,
 98        config: LiteLlmConfig,
 99        kiln_task: datamodel.Task,
100        base_adapter_config: AdapterConfig | None = None,
101    ):
102        if not isinstance(config.run_config_properties, KilnAgentRunConfigProperties):
103            raise ValueError("LiteLlmAdapter requires KilnAgentRunConfigProperties")
104        self.config = config
105        self._additional_body_options = config.additional_body_options
106        self._api_base = config.base_url
107        self._headers = config.default_headers
108        self._litellm_model_id: str | None = None
109        self._cached_available_tools: list[KilnToolInterface] | None = None
110
111        super().__init__(
112            task=kiln_task,
113            run_config=config.run_config_properties,
114            config=base_adapter_config,
115        )
116
117        unmanaged_tools = self.base_adapter_config.unmanaged_tools
118        if unmanaged_tools:
119            _validate_unmanaged_tools(unmanaged_tools)
config
async def acompletion_checking_response( self, **kwargs: Any) -> Tuple[litellm.types.utils.ModelResponse, litellm.types.utils.Choices]:
418    async def acompletion_checking_response(
419        self, **kwargs: Any
420    ) -> Tuple[ModelResponse, Choices]:
421        response = await litellm.acompletion(**kwargs)
422
423        if (
424            not isinstance(response, ModelResponse)
425            or not response.choices
426            or len(response.choices) == 0
427            or not isinstance(response.choices[0], Choices)
428        ):
429            raise RuntimeError(
430                f"Expected ModelResponse with Choices, got {type(response)}."
431            )
432        return response, response.choices[0]
def adapter_name(self) -> str:
434    def adapter_name(self) -> str:
435        return "kiln_openai_compatible_adapter"
async def response_format_options(self) -> dict[str, typing.Any]:
437    async def response_format_options(self) -> dict[str, Any]:
438        # Unstructured if task isn't structured
439        if not self.has_structured_output():
440            return {}
441
442        run_config = as_kiln_agent_run_config(self.run_config)
443        structured_output_mode: StructuredOutputMode = run_config.structured_output_mode
444
445        match structured_output_mode:
446            case StructuredOutputMode.json_mode:
447                return {"response_format": {"type": "json_object"}}
448            case StructuredOutputMode.json_schema:
449                return self.json_schema_response_format()
450            case StructuredOutputMode.function_calling_weak:
451                return self.tool_call_params(strict=False)
452            case StructuredOutputMode.function_calling:
453                return self.tool_call_params(strict=True)
454            case StructuredOutputMode.json_instructions:
455                # JSON instructions dynamically injected in prompt, not the API response format. Do not ask for json_object (see option below).
456                return {}
457            case StructuredOutputMode.json_custom_instructions:
458                # JSON instructions statically injected in system prompt, not the API response format. Do not ask for json_object (see option above).
459                return {}
460            case StructuredOutputMode.json_instruction_and_object:
461                # We set response_format to json_object and also set json instructions in the prompt
462                return {"response_format": {"type": "json_object"}}
463            case StructuredOutputMode.default:
464                provider_name = run_config.model_provider_name
465                if provider_name == ModelProviderName.ollama:
466                    # Ollama added json_schema to all models: https://ollama.com/blog/structured-outputs
467                    return self.json_schema_response_format()
468                elif provider_name == ModelProviderName.docker_model_runner:
469                    # Docker Model Runner uses OpenAI-compatible API with JSON schema support
470                    return self.json_schema_response_format()
471                else:
472                    # Default to function calling -- it's older than the other modes. Higher compatibility.
473                    # Strict isn't widely supported yet, so we don't use it by default unless it's OpenAI.
474                    strict = provider_name == ModelProviderName.openai
475                    return self.tool_call_params(strict=strict)
476            case StructuredOutputMode.unknown:
477                # See above, but this case should never happen.
478                raise ValueError("Structured output mode is unknown.")
479            case _:
480                raise_exhaustive_enum_error(structured_output_mode)  # type: ignore[arg-type]
def json_schema_response_format(self) -> dict[str, typing.Any]:
482    def json_schema_response_format(self) -> dict[str, Any]:
483        output_schema = self.task.output_schema()
484        if output_schema is None:
485            raise ValueError(
486                "Invalid output schema for this task. Cannot use JSON schema response format."
487            )
488        output_schema = close_object_schemas(output_schema, strict=True)
489        # Strip numeric bounds (min/max/etc.) from integer/number nodes for the
490        # json_schema wire format. Some providers (e.g. Claude via OpenRouter,
491        # which maps onto Anthropic's newer output_config.format.schema API)
492        # reject numeric bounds on integer/number types and return HTTP 400.
493        # The valid ranges are still enforced by the prompt + post-hoc
494        # validation, so this only affects the schema sent over the wire.
495        output_schema = strip_numeric_bounds(output_schema)
496        return {
497            "response_format": {
498                "type": "json_schema",
499                "json_schema": {
500                    "name": "task_response",
501                    "schema": output_schema,
502                },
503            }
504        }
def tool_call_params(self, strict: bool) -> dict[str, typing.Any]:
506    def tool_call_params(self, strict: bool) -> dict[str, Any]:
507        # Add additional_properties: false to the schema (OpenAI requires this for some models)
508        output_schema = self.task.output_schema()
509        if not isinstance(output_schema, dict):
510            raise ValueError(
511                "Invalid output schema for this task. Can not use tool calls."
512            )
513        output_schema = close_object_schemas(output_schema, strict=strict)
514
515        function_params = {
516            "name": "task_response",
517            "parameters": output_schema,
518        }
519        # This should be on, but we allow setting function_calling_weak for APIs that don't support it.
520        if strict:
521            function_params["strict"] = True
522
523        return {
524            "tools": [
525                {
526                    "type": "function",
527                    "function": function_params,
528                }
529            ],
530            "tool_choice": {
531                "type": "function",
532                "function": {"name": "task_response"},
533            },
534        }
def build_extra_body( self, provider: kiln_ai.adapters.ml_model_list.KilnModelProvider) -> dict[str, typing.Any]:
536    def build_extra_body(self, provider: KilnModelProvider) -> dict[str, Any]:
537        # Don't love having this logic here. But it's worth the usability improvement
538        # so better to keep it than exclude it. Should figure out how I want to isolate
539        # this sort of logic so it's config driven and can be overridden
540        extra_body: dict[str, Any] = {}
541        provider_options = {}
542
543        run_config = as_kiln_agent_run_config(self.run_config)
544        # For legacy config 'thinking_level' is not set, default to provider's default
545        if "thinking_level" in run_config.model_fields_set:
546            thinking_level = run_config.thinking_level
547        else:
548            thinking_level = provider.default_thinking_level
549
550        # Skip if provider doesn't support thinking levels (stale configs may still have one set)
551        if (
552            thinking_level is not None
553            and provider.available_thinking_levels is not None
554        ):
555            # Anthropic models in OpenRouter uses reasoning object. See https://openrouter.ai/docs/use-cases/reasoning-tokens
556            if (
557                provider.name == ModelProviderName.openrouter
558                and provider.openrouter_reasoning_object
559            ):
560                extra_body["reasoning"] = {"effort": thinking_level}
561            elif (
562                provider.name == ModelProviderName.anthropic
563                and thinking_level == "none"
564            ):
565                # Anthropic's native API has no reasoning_effort="none"; passing it makes
566                # litellm map thinking to None and then crash. Omitting reasoning_effort
567                # disables extended thinking, which also frees temperature from the
568                # temperature=1 requirement that applies whenever thinking is enabled.
569                pass
570            else:
571                extra_body["reasoning_effort"] = thinking_level
572                # Opus 4.7/4.8 default thinking display to "omitted", returning empty
573                # thinking text. Request the summary so reasoning is surfaced. litellm
574                # still maps reasoning_effort to output_config.effort; this only adds the
575                # display to the adaptive thinking object.
576                if (
577                    provider.name == ModelProviderName.anthropic
578                    and provider.anthropic_summarized_thinking
579                ):
580                    extra_body["thinking"] = {
581                        "type": "adaptive",
582                        "display": "summarized",
583                    }
584
585        if provider.require_openrouter_reasoning:
586            # https://openrouter.ai/docs/use-cases/reasoning-tokens
587            extra_body["reasoning"] = {
588                "exclude": False,
589            }
590
591        if provider.gemini_reasoning_enabled:
592            extra_body["reasoning"] = {
593                "enabled": True,
594            }
595
596        if provider.name == ModelProviderName.openrouter:
597            # Ask OpenRouter to include usage in the response (cost)
598            extra_body["usage"] = {"include": True}
599
600            # Set a default provider order for more deterministic routing.
601            # OpenRouter will ignore providers that don't support the model.
602            # Special cases below (like R1) can override this order.
603            # allow_fallbacks is true by default, but we can override it here.
604            provider_options["order"] = [
605                "fireworks",
606                "parasail",
607                "together",
608                "deepinfra",
609                "novita",
610                "groq",
611                "amazon-bedrock",
612                "azure",
613                "nebius",
614            ]
615
616        if provider.anthropic_extended_thinking and "thinking" not in extra_body:
617            extra_body["thinking"] = {"type": "enabled", "budget_tokens": 4000}
618
619        if provider.r1_openrouter_options:
620            # Require providers that support the reasoning parameter
621            provider_options["require_parameters"] = True
622            # Prefer R1 providers with reasonable perf/quants
623            provider_options["order"] = ["fireworks", "together"]
624            # R1 providers with unreasonable quants
625            provider_options["ignore"] = ["deepinfra"]
626
627        # Only set of this request is to get logprobs.
628        if (
629            provider.logprobs_openrouter_options
630            and self.base_adapter_config.top_logprobs is not None
631        ):
632            # Don't let OpenRouter choose a provider that doesn't support logprobs.
633            provider_options["require_parameters"] = True
634            # DeepInfra silently fails to return logprobs consistently.
635            provider_options["ignore"] = ["deepinfra"]
636
637        if provider.openrouter_skip_required_parameters:
638            # Oddball case, R1 14/8/1.5B fail with this param, even though they support thinking params.
639            provider_options["require_parameters"] = False
640
641        # Siliconflow uses a bool flag for thinking, for some models
642        if provider.siliconflow_enable_thinking is not None:
643            extra_body["enable_thinking"] = provider.siliconflow_enable_thinking
644
645        if len(provider_options) > 0:
646            extra_body["provider"] = provider_options
647
648        return extra_body
def litellm_model_id(self) -> str:
650    def litellm_model_id(self) -> str:
651        # The model ID is an interesting combination of format and url endpoint.
652        # It specifics the provider URL/host, but this is overridden if you manually set an api url
653        if self._litellm_model_id:
654            return self._litellm_model_id
655
656        litellm_provider_info = get_litellm_provider_info(self.model_provider())
657        if litellm_provider_info.is_custom and self._api_base is None:
658            raise ValueError(
659                "Explicit Base URL is required for OpenAI compatible APIs (custom models, ollama, fine tunes, and custom registry models)"
660            )
661
662        self._litellm_model_id = litellm_provider_info.litellm_model_id
663        return self._litellm_model_id
async def build_completion_kwargs( self, provider: kiln_ai.adapters.ml_model_list.KilnModelProvider, messages: list[typing.Union[openai.types.chat.chat_completion_developer_message_param.ChatCompletionDeveloperMessageParam, openai.types.chat.chat_completion_system_message_param.ChatCompletionSystemMessageParam, openai.types.chat.chat_completion_user_message_param.ChatCompletionUserMessageParam, kiln_ai.utils.open_ai_types.ChatCompletionAssistantMessageParamWrapper, kiln_ai.utils.open_ai_types.ChatCompletionToolMessageParamWrapper, openai.types.chat.chat_completion_function_message_param.ChatCompletionFunctionMessageParam, litellm.types.utils.Message]], top_logprobs: int | None, skip_response_format: bool = False) -> dict[str, typing.Any]:
700    async def build_completion_kwargs(
701        self,
702        provider: KilnModelProvider,
703        messages: list[ChatCompletionMessageIncludingLiteLLM],
704        top_logprobs: int | None,
705        skip_response_format: bool = False,
706    ) -> dict[str, Any]:
707        run_config = as_kiln_agent_run_config(self.run_config)
708        extra_body = self.build_extra_body(provider)
709
710        # Merge all parameters into a single kwargs dict for litellm
711        completion_kwargs = {
712            "model": self.litellm_model_id(),
713            "messages": messages,
714            "api_base": self._api_base,
715            "headers": self._headers,
716            "temperature": run_config.temperature,
717            "top_p": run_config.top_p,
718            # This drops params that are not supported by the model. Only openai params like top_p, temperature -- not litellm params like model, etc.
719            # Not all models and providers support all openai params (for example, o3 doesn't support top_p)
720            # Better to ignore them than to fail the model call.
721            # https://docs.litellm.ai/docs/completion/input
722            "drop_params": True,
723            **extra_body,
724            **self._additional_body_options,
725        }
726
727        if self.base_adapter_config.automatic_prompt_caching:
728            # Mark the last message for cache control. Litellm's AnthropicCacheControlHook
729            # handles provider-specific injection. Providers auto-cache matching prefixes,
730            # so marking the last message is sufficient for multi-turn conversations.
731            completion_kwargs["cache_control_injection_points"] = [
732                {"location": "message", "index": -1}
733            ]
734
735        tool_calls = await self.litellm_tools()
736        has_tools = len(tool_calls) > 0
737        if has_tools:
738            completion_kwargs["tools"] = tool_calls
739            completion_kwargs["tool_choice"] = "auto"
740
741        # Special condition for Claude Opus 4.1 and Sonnet 4.5, where we can only specify top_p or temp, not both.
742        # Remove default values (1.0) prioritizing anything the user customized, then error with helpful message if they are both custom.
743        if provider.temp_top_p_exclusive:
744            if "top_p" in completion_kwargs and completion_kwargs["top_p"] == 1.0:
745                del completion_kwargs["top_p"]
746            if (
747                "temperature" in completion_kwargs
748                and completion_kwargs["temperature"] == 1.0
749            ):
750                del completion_kwargs["temperature"]
751            if "top_p" in completion_kwargs and "temperature" in completion_kwargs:
752                raise ValueError(
753                    "top_p and temperature can not both have custom values for this model. This is a restriction from the model provider. Please set only one of them to a custom value (not 1.0)."
754                )
755
756        if not skip_response_format:
757            # Response format: json_schema, json_instructions, json_mode, function_calling, etc
758            response_format_options = await self.response_format_options()
759
760            # Check for a conflict between tools and response format using tools
761            # We could reconsider this. Model could be able to choose between a final answer or a tool call on any turn. However, good models for tools tend to also support json_schea, so do we need to support both? If we do, merge them, and consider auto vs forced when merging (only forced for final, auto for merged).
762            if has_tools and "tools" in response_format_options:
763                raise ValueError(
764                    "Function calling/tools can't be used as the JSON response format if you're also using tools. Please select a different structured output mode."
765                )
766
767            completion_kwargs.update(response_format_options)
768
769        if top_logprobs is not None:
770            completion_kwargs["logprobs"] = True
771            completion_kwargs["top_logprobs"] = top_logprobs
772
773        # any params listed in this list will be passed to the model regardless of LiteLLM's own validation
774        allowed_openai_params = self._allowed_openai_params_for_completion_kwargs(
775            completion_kwargs
776        )
777        if len(allowed_openai_params) > 0:
778            completion_kwargs["allowed_openai_params"] = allowed_openai_params
779
780        completion_kwargs["messages"] = sanitize_messages_for_provider(messages)
781
782        return completion_kwargs
def usage_from_response( self, response: litellm.types.utils.ModelResponse) -> kiln_ai.datamodel.MessageUsage:
784    def usage_from_response(self, response: ModelResponse) -> MessageUsage:
785        litellm_usage = response.get("usage", None)
786
787        # LiteLLM isn't consistent in how it returns the cost.
788        cost = response._hidden_params.get("response_cost", None)
789        if cost is None and litellm_usage:
790            cost = litellm_usage.get("cost", None)
791
792        usage = MessageUsage()
793
794        if not litellm_usage and not cost:
795            return usage
796
797        if litellm_usage and isinstance(litellm_usage, LiteLlmUsage):
798            usage.input_tokens = litellm_usage.get("prompt_tokens", None)
799            usage.output_tokens = litellm_usage.get("completion_tokens", None)
800            usage.total_tokens = litellm_usage.get("total_tokens", None)
801            prompt_details = litellm_usage.get("prompt_tokens_details", None)
802            if prompt_details and hasattr(prompt_details, "cached_tokens"):
803                usage.cached_tokens = prompt_details.cached_tokens
804            elif prompt_details:
805                logger.warning(
806                    f"prompt_tokens_details has unexpected type {type(prompt_details)}, cached_tokens not extracted"
807                )
808        else:
809            logger.warning(
810                f"Unexpected usage format from litellm: {litellm_usage}. Expected Usage object, got {type(litellm_usage)}"
811            )
812
813        if isinstance(cost, float):
814            usage.cost = cost
815        elif cost is not None:
816            # None is allowed, but no other types are expected
817            logger.warning(
818                f"Unexpected cost format from litellm: {cost}. Expected float, got {type(cost)}"
819            )
820
821        return usage
async def cached_available_tools(self) -> list[kiln_ai.tools.KilnToolInterface]:
823    async def cached_available_tools(self) -> list[KilnToolInterface]:
824        if self._cached_available_tools is None:
825            self._cached_available_tools = await self.available_tools()
826        return self._cached_available_tools
async def litellm_tools(self) -> list[kiln_ai.tools.base_tool.ToolCallDefinition]:
834    async def litellm_tools(self) -> list[ToolCallDefinition]:
835        available_tools = await self.cached_available_tools()
836
837        registry_defs = [await tool.toolcall_definition() for tool in available_tools]
838        unmanaged = self.base_adapter_config.unmanaged_tools
839        unmanaged_defs = (
840            [await t.toolcall_definition() for t in unmanaged] if unmanaged else []
841        )
842
843        merged = registry_defs + unmanaged_defs
844        seen_names: set[str] = set()
845        for d in merged:
846            name = d["function"]["name"]
847            if name in seen_names:
848                raise ValueError(
849                    f"Duplicate tool name {name!r}: unmanaged and registry tools must have unique names."
850                )
851            seen_names.add(name)
852
853        return merged
async def process_tool_calls( self, tool_calls: list[litellm.types.utils.ChatCompletionMessageToolCall] | None) -> tuple[str | None, list[kiln_ai.utils.open_ai_types.ChatCompletionToolMessageParamWrapper]]:
855    async def process_tool_calls(
856        self, tool_calls: list[ChatCompletionMessageToolCall] | None
857    ) -> tuple[str | None, list[ChatCompletionToolMessageParamWrapper]]:
858        if tool_calls is None:
859            return None, []
860
861        assistant_output_from_toolcall: str | None = None
862        tool_call_response_messages: list[ChatCompletionToolMessageParamWrapper] = []
863        tool_run_coroutines = []
864
865        for tool_call in tool_calls:
866            # Kiln "task_response" tool is used for returning structured output via tool calls.
867            # Load the output from the tool call. Also
868            if tool_call.function.name == "task_response":
869                assistant_output_from_toolcall = tool_call.function.arguments
870                continue
871
872            # Process normal tool calls (not the "task_response" tool)
873            tool_name = tool_call.function.name
874            tool = None
875            for tool_option in await self._tools_for_execution():
876                if await tool_option.name() == tool_name:
877                    tool = tool_option
878                    break
879            if not tool:
880                raise RuntimeError(
881                    f"A tool named '{tool_name}' was invoked by a model, but was not available."
882                )
883
884            # Parse the arguments and validate them against the tool's schema
885            try:
886                parsed_args = json.loads(tool_call.function.arguments)
887            except json.JSONDecodeError:
888                raise RuntimeError(
889                    f"Failed to parse arguments for tool '{tool_name}' (should be JSON): {tool_call.function.arguments}"
890                )
891            try:
892                tool_call_definition = await tool.toolcall_definition()
893                json_schema = json.dumps(tool_call_definition["function"]["parameters"])
894                validate_schema_with_value_error(parsed_args, json_schema)
895            except Exception as e:
896                raise RuntimeError(
897                    f"Failed to validate arguments for tool '{tool_name}'. The arguments didn't match the tool's schema. The arguments were: {parsed_args}\n The error was: {e}"
898                ) from e
899
900            # Create context with the calling task's allow_saving setting
901            context = ToolCallContext(
902                allow_saving=self.base_adapter_config.allow_saving
903            )
904
905            async def run_tool_and_format(
906                t=tool, c=context, args=parsed_args, tc_id=tool_call.id
907            ):
908                result = await t.run(c, **args)
909                return ChatCompletionToolMessageParamWrapper(
910                    role="tool",
911                    tool_call_id=tc_id,
912                    content=result.output,
913                    kiln_task_tool_data=result.kiln_task_tool_data
914                    if isinstance(result, KilnTaskToolResult)
915                    else None,
916                    is_error=result.is_error if result.is_error else None,
917                    error_message=result.error_message
918                    if result.error_message
919                    else None,
920                )
921
922            tool_run_coroutines.append(run_tool_and_format())
923
924        if tool_run_coroutines:
925            tool_call_response_messages = await asyncio.gather(*tool_run_coroutines)
926
927        if (
928            assistant_output_from_toolcall is not None
929            and len(tool_call_response_messages) > 0
930        ):
931            raise RuntimeError(
932                "Model asked for impossible combination: task_response tool call and other tool calls were both provided in the same turn. This is not supported as it means the model asked us to both return task_response results (ending the turn) and run new tools calls to send back to the model. If the model makes this mistake often, try a difference structured data model like JSON schema, where this is impossible."
933            )
934
935        return assistant_output_from_toolcall, tool_call_response_messages
def litellm_message_to_trace_message( self, raw_message: litellm.types.utils.Message, latency_ms: int | None = None, usage: kiln_ai.datamodel.MessageUsage | None = None) -> kiln_ai.utils.open_ai_types.ChatCompletionAssistantMessageParamWrapper:
937    def litellm_message_to_trace_message(
938        self,
939        raw_message: LiteLLMMessage,
940        latency_ms: int | None = None,
941        usage: MessageUsage | None = None,
942    ) -> ChatCompletionAssistantMessageParamWrapper:
943        """
944        Convert a LiteLLM Message object to an OpenAI compatible message, our ChatCompletionAssistantMessageParamWrapper
945        """
946        message: ChatCompletionAssistantMessageParamWrapper = {
947            "role": "assistant",
948        }
949        if raw_message.role != "assistant":
950            raise ValueError(
951                "Model returned a message with a role other than assistant. This is not supported."
952            )
953
954        if hasattr(raw_message, "content"):
955            message["content"] = raw_message.content
956        if hasattr(raw_message, "reasoning_content"):
957            message["reasoning_content"] = raw_message.reasoning_content
958        if hasattr(raw_message, "tool_calls"):
959            # Convert ChatCompletionMessageToolCall to ChatCompletionMessageToolCallParam
960            open_ai_tool_calls: List[ChatCompletionMessageToolCallParam] = []
961            for litellm_tool_call in raw_message.tool_calls or []:
962                # Optional in the SDK for streaming responses, but should never be None at this point.
963                if litellm_tool_call.function.name is None:
964                    raise ValueError(
965                        "The model requested a tool call, without providing a function name (required)."
966                    )
967                open_ai_tool_calls.append(
968                    ChatCompletionMessageToolCallParam(
969                        id=litellm_tool_call.id,
970                        type="function",
971                        function={
972                            "name": litellm_tool_call.function.name,
973                            "arguments": litellm_tool_call.function.arguments,
974                        },
975                    )
976                )
977            if len(open_ai_tool_calls) > 0:
978                message["tool_calls"] = open_ai_tool_calls
979
980        if latency_ms is not None:
981            message["latency_ms"] = latency_ms
982
983        if usage is not None:
984            message["usage"] = usage
985
986        if not message.get("content") and not message.get("tool_calls"):
987            raise ValueError(EMPTY_RESPONSE_ERROR_MESSAGE)
988
989        return message

Convert a LiteLLM Message object to an OpenAI compatible message, our ChatCompletionAssistantMessageParamWrapper

def all_messages_to_trace( self, messages: list[typing.Union[openai.types.chat.chat_completion_developer_message_param.ChatCompletionDeveloperMessageParam, openai.types.chat.chat_completion_system_message_param.ChatCompletionSystemMessageParam, openai.types.chat.chat_completion_user_message_param.ChatCompletionUserMessageParam, kiln_ai.utils.open_ai_types.ChatCompletionAssistantMessageParamWrapper, kiln_ai.utils.open_ai_types.ChatCompletionToolMessageParamWrapper, openai.types.chat.chat_completion_function_message_param.ChatCompletionFunctionMessageParam, litellm.types.utils.Message]], message_latency: dict[int, int] | None = None, message_usage: dict[int, kiln_ai.datamodel.MessageUsage] | None = None) -> list[typing.Union[openai.types.chat.chat_completion_developer_message_param.ChatCompletionDeveloperMessageParam, openai.types.chat.chat_completion_system_message_param.ChatCompletionSystemMessageParam, openai.types.chat.chat_completion_user_message_param.ChatCompletionUserMessageParam, kiln_ai.utils.open_ai_types.ChatCompletionAssistantMessageParamWrapper, kiln_ai.utils.open_ai_types.ChatCompletionToolMessageParamWrapper, openai.types.chat.chat_completion_function_message_param.ChatCompletionFunctionMessageParam]]:
 991    def all_messages_to_trace(
 992        self,
 993        messages: list[ChatCompletionMessageIncludingLiteLLM],
 994        message_latency: dict[int, int] | None = None,
 995        message_usage: dict[int, MessageUsage] | None = None,
 996    ) -> list[ChatCompletionMessageParam]:
 997        """
 998        Internally we allow LiteLLM Message objects, but for trace we need OpenAI compatible types. Replace LiteLLM Message objects with OpenAI compatible types.
 999
1000        Non-LiteLLM dict messages pass through unchanged. Any per-message
1001        ``usage``/``latency_ms`` already attached to those dicts (e.g. from a
1002        seeded prior trace) is preserved.
1003        """
1004        trace: list[ChatCompletionMessageParam] = []
1005        for i, message in enumerate(messages):
1006            if isinstance(message, LiteLLMMessage):
1007                latency_ms = message_latency.get(i) if message_latency else None
1008                usage = message_usage.get(i) if message_usage else None
1009                trace.append(
1010                    self.litellm_message_to_trace_message(message, latency_ms, usage)
1011                )
1012            else:
1013                trace.append(message)
1014        return trace

Internally we allow LiteLLM Message objects, but for trace we need OpenAI compatible types. Replace LiteLLM Message objects with OpenAI compatible types.

Non-LiteLLM dict messages pass through unchanged. Any per-message usage/latency_ms already attached to those dicts (e.g. from a seeded prior trace) is preserved.