From c4b4cd87d050ffbdddc3c187228c6d65a570f3de Mon Sep 17 00:00:00 2001 From: asimurka Date: Tue, 3 Nov 2026 14:09:02 +0100 Subject: [PATCH] Implemented llm settings resolver --- src/providers/inference/README.md | 4 + src/providers/inference/llm_settings.py | 123 +++++++++++++ tests/unit/providers/inference/README.md | 4 + .../providers/inference/test_llm_settings.py | 165 ++++++++++++++++++ 4 files changed, 296 insertions(+) create mode 100644 src/providers/inference/llm_settings.py create mode 100644 tests/unit/providers/inference/test_llm_settings.py diff --git a/src/providers/inference/README.md b/src/providers/inference/README.md index 7d3e89816..eff169fc3 100644 --- a/src/providers/inference/README.md +++ b/src/providers/inference/README.md @@ -28,6 +28,10 @@ Singleton registry of initialized inference providers. Soft-fallback helpers for request-path model resolution. +## [llm_settings.py](llm_settings.py) + +Helpers for LLM request settings and Pydantic AI run configuration. + ## [types.py](types.py) Shared literals and aliases for the inference provider registry. diff --git a/src/providers/inference/llm_settings.py b/src/providers/inference/llm_settings.py new file mode 100644 index 000000000..91af401ff --- /dev/null +++ b/src/providers/inference/llm_settings.py @@ -0,0 +1,123 @@ +"""Helpers for LLM request settings and Pydantic AI run configuration.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal, Optional, Self, cast + +from pydantic import BaseModel, ConfigDict, Field +from pydantic_ai.settings import ModelSettings, ThinkingLevel +from pydantic_ai.usage import UsageLimits + +if TYPE_CHECKING: + from models.api.requests.responses_openai import ResponsesRequest + +ReasoningSummary = Literal["auto", "concise", "detailed"] +TextVerbosity = Literal["low", "medium", "high"] + +_USAGE_EXCLUDE = {"max_tool_calls", "max_infer_iters"} + + +class LlmRunSettings(BaseModel): + """Shared generation and loop-limit options from Responses or query requests. + + Attributes: + max_output_tokens: Maximum tokens to generate. + temperature: Sampling temperature. + parallel_tool_calls: Whether parallel tool calls are allowed. + prompt_cache_key: Provider prompt-cache key when supported. + safety_identifier: End-user safety identifier when supported. + thinking: Pydantic AI unified thinking or reasoning level. + reasoning_summary: Reasoning summary mode (Responses-style). + text_verbosity: Text verbosity (Responses-style). + max_tool_calls: Maximum successful tool calls for the agent run. + max_infer_iters: Maximum model requests for the agent run. + + """ + + model_config = ConfigDict(extra="forbid") + + max_output_tokens: Optional[int] = Field( + default=None, serialization_alias="max_tokens" + ) + temperature: Optional[float] = None + parallel_tool_calls: Optional[bool] = None + prompt_cache_key: Optional[str] = Field( + default=None, serialization_alias="openai_prompt_cache_key" + ) + safety_identifier: Optional[str] = Field( + default=None, serialization_alias="openai_user" + ) + thinking: Optional[ThinkingLevel] = None + reasoning_summary: Optional[ReasoningSummary] = Field( + default=None, serialization_alias="openai_reasoning_summary" + ) + text_verbosity: Optional[TextVerbosity] = Field( + default=None, serialization_alias="openai_text_verbosity" + ) + max_tool_calls: Optional[int] = None + max_infer_iters: Optional[int] = None + + @classmethod + def from_responses_request(cls, request: ResponsesRequest) -> Self: + """Build run settings from an OpenResponses request. + + Args: + request: Responses API request body. + + Returns: + Shared LLM run settings extracted from the request. + + """ + thinking: Optional[ThinkingLevel] = None + reasoning_summary: Optional[ReasoningSummary] = None + if request.reasoning is not None: + if request.reasoning.effort is not None: + thinking = ( + False + if request.reasoning.effort == "none" + else request.reasoning.effort + ) + reasoning_summary = request.reasoning.summary + + text_verbosity: Optional[TextVerbosity] = None + if request.text is not None: + text_verbosity = request.text.verbosity + + return cls( + max_output_tokens=request.max_output_tokens, + temperature=request.temperature, + parallel_tool_calls=request.parallel_tool_calls, + prompt_cache_key=request.prompt_cache_key, + safety_identifier=request.safety_identifier, + thinking=thinking, + reasoning_summary=reasoning_summary, + text_verbosity=text_verbosity, + max_tool_calls=request.max_tool_calls, + max_infer_iters=request.max_infer_iters, + ) + + def to_model_settings(self) -> Optional[ModelSettings]: + """Convert generation fields into Pydantic AI ModelSettings. + + Returns: + ModelSettings dict, or None when no generation fields are set. + + """ + data = self.model_dump(by_alias=True, exclude_none=True, exclude=_USAGE_EXCLUDE) + return cast(ModelSettings, data) if data else None + + def to_usage_limits(self) -> Optional[UsageLimits]: + """Convert loop-limit fields into Pydantic AI UsageLimits. + + Returns: + Usage limits for the agent run, or None when no loop limits are + set. Unset fields keep Pydantic AI UsageLimits defaults. + + """ + if self.max_infer_iters is None and self.max_tool_calls is None: + return None + + limits = UsageLimits(tool_calls_limit=self.max_tool_calls) + if self.max_infer_iters is not None: + limits.request_limit = self.max_infer_iters + return limits diff --git a/tests/unit/providers/inference/README.md b/tests/unit/providers/inference/README.md index d55f1848c..4118f6280 100644 --- a/tests/unit/providers/inference/README.md +++ b/tests/unit/providers/inference/README.md @@ -32,3 +32,7 @@ Unit tests for the ProviderRegistry singleton. Unit tests for soft-fallback resolution helpers. +## [test_llm_settings.py](test_llm_settings.py) + +Unit tests for shared LLM settings mapping. + diff --git a/tests/unit/providers/inference/test_llm_settings.py b/tests/unit/providers/inference/test_llm_settings.py new file mode 100644 index 000000000..5c6b30d14 --- /dev/null +++ b/tests/unit/providers/inference/test_llm_settings.py @@ -0,0 +1,165 @@ +"""Unit tests for shared LLM settings mapping.""" + +from ogx_api.openai_responses import ( + OpenAIResponseReasoning as Reasoning, +) +from ogx_api.openai_responses import ( + OpenAIResponseText as Text, +) +from pydantic_ai.usage import UsageLimits + +from models.api.requests.responses_openai import ResponsesRequest +from providers.inference.llm_settings import LlmRunSettings + + +def test_to_model_settings_maps_generation_fields() -> None: + """Generation knobs map onto unified and OpenAI-prefixed ModelSettings.""" + settings = LlmRunSettings( + max_output_tokens=128, + temperature=0.4, + parallel_tool_calls=False, + prompt_cache_key="cache-1", + safety_identifier="user-1", + thinking="low", + reasoning_summary="concise", + text_verbosity="medium", + max_tool_calls=3, + max_infer_iters=5, + ) + + assert settings.to_model_settings() == { + "max_tokens": 128, + "temperature": 0.4, + "parallel_tool_calls": False, + "openai_prompt_cache_key": "cache-1", + "openai_user": "user-1", + "thinking": "low", + "openai_reasoning_summary": "concise", + "openai_text_verbosity": "medium", + } + + +def test_to_model_settings_omits_none_and_keeps_false() -> None: + """Dump drops unset fields but keeps explicit False values.""" + settings = LlmRunSettings(temperature=0.2, parallel_tool_calls=False) + + assert settings.to_model_settings() == { + "temperature": 0.2, + "parallel_tool_calls": False, + } + + +def test_to_model_settings_applies_each_serialization_alias() -> None: + """Each aliased field dumps under its ModelSettings key alone.""" + assert LlmRunSettings(max_output_tokens=10).to_model_settings() == { + "max_tokens": 10 + } + assert LlmRunSettings(prompt_cache_key="ck").to_model_settings() == { + "openai_prompt_cache_key": "ck" + } + assert LlmRunSettings(safety_identifier="sid").to_model_settings() == { + "openai_user": "sid" + } + assert LlmRunSettings(reasoning_summary="auto").to_model_settings() == { + "openai_reasoning_summary": "auto" + } + assert LlmRunSettings(text_verbosity="high").to_model_settings() == { + "openai_text_verbosity": "high" + } + + +def test_from_responses_maps_reasoning_none_to_thinking_false() -> None: + """Effort none becomes PAI thinking=False in the factory.""" + request = ResponsesRequest( + input="hello", + reasoning=Reasoning(effort="none"), + ) + + assert LlmRunSettings.from_responses_request(request).thinking is False + + +def test_to_model_settings_returns_none_when_only_usage_fields() -> None: + """Usage-only settings yield None rather than an empty ModelSettings dict.""" + assert LlmRunSettings(max_tool_calls=2).to_model_settings() is None + + +def test_to_usage_limits_maps_loop_caps() -> None: + """Loop limits map onto UsageLimits request and tool-call caps.""" + settings = LlmRunSettings(max_infer_iters=7, max_tool_calls=4) + + assert settings.to_usage_limits() == UsageLimits( + request_limit=7, tool_calls_limit=4 + ) + + +def test_to_usage_limits_preserves_default_request_limit() -> None: + """Only max_tool_calls keeps PAI's default request_limit.""" + limits = LlmRunSettings(max_tool_calls=2).to_usage_limits() + + assert limits is not None + assert limits.tool_calls_limit == 2 + assert limits.request_limit == 50 + + +def test_to_usage_limits_maps_infer_iters_only() -> None: + """Only max_infer_iters sets request_limit and leaves tool_calls_limit unset.""" + limits = LlmRunSettings(max_infer_iters=3).to_usage_limits() + + assert limits == UsageLimits(request_limit=3, tool_calls_limit=None) + + +def test_to_usage_limits_returns_none_when_empty() -> None: + """No loop limits yields None.""" + assert LlmRunSettings(temperature=0.1).to_usage_limits() is None + + +def test_from_responses_request_minimal_request() -> None: + """A bare Responses request yields empty optional generation fields.""" + settings = LlmRunSettings.from_responses_request(ResponsesRequest(input="hello")) + + assert settings == LlmRunSettings() + assert settings.to_model_settings() is None + assert settings.to_usage_limits() is None + + +def test_from_responses_request_extracts_mapped_fields_only() -> None: + """Responses extraction keeps MS/usage fields and drops agent-only ones.""" + request = ResponsesRequest( + input="hello", + max_output_tokens=64, + temperature=0.5, + parallel_tool_calls=True, + prompt_cache_key="ck", + safety_identifier="sid", + reasoning=Reasoning(effort="high", summary="detailed"), + text=Text(verbosity="low"), + max_tool_calls=9, + max_infer_iters=8, + store=False, + stream=True, + instructions="system", + tools=[], + ) + + assert LlmRunSettings.from_responses_request(request) == LlmRunSettings( + max_output_tokens=64, + temperature=0.5, + parallel_tool_calls=True, + prompt_cache_key="ck", + safety_identifier="sid", + thinking="high", + reasoning_summary="detailed", + text_verbosity="low", + max_tool_calls=9, + max_infer_iters=8, + ) + + +def test_from_responses_request_uses_reasoning_summary() -> None: + """Reasoning summary is taken from the non-deprecated summary field.""" + request = ResponsesRequest( + input="hello", + reasoning=Reasoning(summary="auto"), + ) + + assert LlmRunSettings.from_responses_request(request).reasoning_summary == "auto"