Repository navigation
LCORE-4701: Implemented llm settings resolver #3136
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
|
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [low] test-inadequate The test test_to_usage_limits_preserves_default_request_limit hardcodes 50 as the expected default value of UsageLimits.request_limit, coupling the test to an implementation detail of pydantic-ai. Suggested fix: Assert against UsageLimits().request_limit instead of the magic number 50. |
||
|
|
||
| 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" | ||
Uh oh!
There was an error while loading. Please reload this page.