Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/providers/inference/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
123 changes: 123 additions & 0 deletions src/providers/inference/llm_settings.py
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"
)
Comment thread
asimurka marked this conversation as resolved.
Resolved
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
4 changes: 4 additions & 0 deletions tests/unit/providers/inference/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

165 changes: 165 additions & 0 deletions tests/unit/providers/inference/test_llm_settings.py
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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"
Loading