Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from ..common._endpoints_manager import EndpointManager
from ..common._execution_context import UiPathExecutionContext
from ..common._models import Endpoint
from ._model_capabilities import should_skip_temperature
from .llm_gateway import (
ChatCompletion,
SpecificToolChoice,
Expand Down Expand Up @@ -175,6 +176,7 @@ def __init__(
action_id: Optional[str] = None,
) -> None:
super().__init__(config=config, execution_context=execution_context)
self._agenthub_config = agenthub_config
self._llm_headers = _build_llm_headers(
requesting_product, requesting_feature, agenthub_config, action_id
)
Expand Down Expand Up @@ -329,13 +331,18 @@ class Country(BaseModel):

is_reasoning_model = model.lower().startswith(("o1", "o3", "o4"))

# Reasoning models (o1, o3, o4) don't support temperature; newer models
# reject it too, which only discovery knows about.
skip_temperature = is_reasoning_model or await should_skip_temperature(
self, model, self._agenthub_config
)

request_body: dict[str, Any] = {
"messages": messages,
"max_tokens": max_tokens,
}

# Reasoning models (o1, o3, o4) don't support temperature
if not is_reasoning_model:
if not skip_temperature:
request_body["temperature"] = temperature

# Handle response_format - convert BaseModel to schema if needed
Expand Down Expand Up @@ -392,6 +399,7 @@ def __init__(
action_id: Optional[str] = None,
) -> None:
super().__init__(config=config, execution_context=execution_context)
self._agenthub_config = agenthub_config
self._llm_headers = _build_llm_headers(
requesting_product, requesting_feature, agenthub_config, action_id
)
Expand Down Expand Up @@ -558,13 +566,18 @@ class Country(BaseModel):
is_claude_model = "claude" in model_lower
is_reasoning_model = model_lower.startswith(("o1", "o3", "o4"))

# Reasoning models (o1, o3, o4) don't support temperature; newer models
# reject it too, which only discovery knows about.
skip_temperature = is_reasoning_model or await should_skip_temperature(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we remove is_reasining_model check here ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question, but I'd keep it — and it can't be deleted outright anyway.

Two reasons to keep it in the or:

  1. should_skip_temperature fails open by design. It returns False for an unknown model, unreachable discovery, or any unexpected exception. If is_reasoning_model comes out of the expression, a discovery outage means o1/o3/o4 start receiving temperature again — reintroducing exactly the 400 this PR exists to fix, for models that were previously safe. It gets worse combined with the caching behaviour I flagged separately: _details_for caches the failure, so one blip at process start would keep reasoning models 400ing for the whole process lifetime.

  2. It short-circuits. Python or means a known reasoning model never makes the discovery call at all — no round-trip, no latency, no dependency.

There's also an unverified assumption buried in the removal: that gateway discovery actually reports shouldSkipTemperature: true for o1/o3/o4. Worth checking empirically, but even if it does, point 1 still argues for keeping the local guard as the deterministic floor.

And separately — the variable itself is load-bearing elsewhere: line 584, if not is_claude_model and not is_reasoning_model:, gates n, frequency_penalty, presence_penalty and top_p. So is_reasoning_model has to stay regardless; the only question was whether to drop it from the skip_temperature expression.

Net: the local check is a cheap deterministic floor, discovery is the extension for models we can't name ahead of time. Belt and braces is right here.

self, model, self._agenthub_config
)

request_body: dict[str, Any] = {
"messages": converted_messages,
"max_tokens": max_tokens,
}

# Reasoning models (o1, o3, o4) don't support temperature and sampling parameters
if not is_reasoning_model:
if not skip_temperature:
request_body["temperature"] = temperature

# Only add OpenAI-specific parameters for non-Claude and non-reasoning models
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""Per-model parameter support, resolved from the LLM Gateway discovery API.

Newer models reject parameters that older ones accept: passing `temperature` to
a model that dropped it fails the whole request with HTTP 400. The gateway
publishes these constraints per model under `modelDetails`, so callers can strip
the parameter instead of guessing from the model name.
"""

import logging
from typing import Any

from ..common._base_service import BaseService
from ..common._endpoints_manager import EndpointManager
from ..common._models import Endpoint
from ..constants import HEADER_AGENTHUB_CONFIG

logger = logging.getLogger(__name__)

CacheKey = tuple[str, str | None]

# One fetch per (base_url, agenthub_config); an empty mapping caches "couldn't read
# discovery". Deliberately unlocked: a racing duplicate GET is cheaper than an
# asyncio.Lock pinned to whichever event loop touched it first.
_model_details: dict[CacheKey, dict[str, dict[str, Any]]] = {}


async def should_skip_temperature(
service: BaseService, model: str, agenthub_config: str | None
) -> bool:
"""Whether `model` rejects the `temperature` parameter.

Args:
service: Service used to call discovery; supplies base URL and auth.
model: Model name as sent to the gateway.
agenthub_config: AgentHub config scoping which models are visible.

Returns:
True only when discovery explicitly reports the model skips temperature.
Unknown models, unreachable discovery, and any unexpected failure return
False, leaving the caller's request untouched.
"""
try:
details = await _details_for(service, model, agenthub_config)
except Exception as e:
logger.warning(
"Could not resolve parameter support for model %s (%s); "
"sending model parameters unchanged",
model,
e,
)
return False

return bool(details.get("shouldSkipTemperature", False))


async def _details_for(
service: BaseService, model: str, agenthub_config: str | None
) -> dict[str, Any]:
key = (service._config.base_url, agenthub_config)

if key not in _model_details:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This caches a discovery failure the same way it caches a real result. _fetch_model_details returns {} both when the GET raises (line 76-83) or returns a malformed payload (line 85-87), and when discovery genuinely lists zero models. Either way, _details_for writes it into _model_details[key] here, permanently — no TTL, no retry, gated only on key not in _model_details.

One transient discovery blip at process startup (timeout, 5xx, network hiccup) poisons _model_details[key] = {} for the rest of that process's life. Every later call to should_skip_temperature for any model under that (base_url, agenthub_config) resolves via {}.get(model, {})False, so temperature-skipping stays permanently disabled with no recovery short of a restart.

This is the exact regression called out before this PR was cut: must not cache discovery failures, only successes, or add a TTL. Suggest not writing to _model_details on the except paths in _fetch_model_details (lines 76-83, 85-87), so a future call retries instead of latching onto the failure.

_model_details[key] = await _fetch_model_details(service, agenthub_config)
Comment on lines +61 to +62

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Retry discovery after a failed fetch

When the first discovery request for a cache key is transiently unavailable or returns an invalid payload, _fetch_model_details returns {}, which is stored here as a successful process-lifetime result. Every later call for a model such as Sonnet 5 therefore keeps sending the rejected temperature parameter even after discovery recovers, so a brief outage can break all subsequent completions until process restart. Avoid caching failed/invalid reads, or expire and retry them.

Useful? React with 👍 / 👎.


return _model_details[key].get(model, {})


async def _fetch_model_details(
service: BaseService, agenthub_config: str | None
) -> dict[str, dict[str, Any]]:
headers = {HEADER_AGENTHUB_CONFIG: agenthub_config} if agenthub_config else {}
endpoint = Endpoint("/" + EndpointManager.get_discovery_endpoint())

try:
response = await service.request_async("GET", endpoint, headers=headers)
models = response.json()
except Exception as e:
# Falling back to the caller's parameters is the pre-existing behaviour;
# failing the LLM call because discovery is down would be worse.
logger.warning(
"LLM Gateway discovery unavailable (%s); sending model parameters unchanged",
e,
)
return {}

if not isinstance(models, list):
logger.warning("Unexpected LLM Gateway discovery payload; expected a list")
return {}

return {
model["modelName"]: model.get("modelDetails") or {}
for model in models
if isinstance(model, dict) and model.get("modelName")
}


def _reset_cache() -> None:
"""Clear the discovery cache. For tests."""
_model_details.clear()
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class UiPathEndpoints(Enum):
"agenthub_/llm/raw/vendor/{vendor}/model/{model}/completions"
)
AH_CAPABILITIES_ENDPOINT = "agenthub_/llm/api/capabilities"
AH_DISCOVERY_ENDPOINT = "agenthub_/llm/api/discovery"

OR_NORMALIZED_COMPLETION_ENDPOINT = "orchestrator_/llm/api/chat/completions"
OR_PASSTHROUGH_COMPLETION_ENDPOINT = "orchestrator_/llm/openai/deployments/{model}/chat/completions?api-version={api_version}"
Expand All @@ -29,6 +30,7 @@ class UiPathEndpoints(Enum):
"orchestrator_/llm/raw/vendor/{vendor}/model/{model}/completions"
)
OR_CAPABILITIES_ENDPOINT = "orchestrator_/llm/api/capabilities"
OR_DISCOVERY_ENDPOINT = "orchestrator_/llm/api/discovery"


class EndpointManager:
Expand Down Expand Up @@ -185,6 +187,14 @@ def get_normalized_endpoint(cls) -> str:
UiPathEndpoints.OR_NORMALIZED_COMPLETION_ENDPOINT,
)

@classmethod
def get_discovery_endpoint(cls) -> str:
"""Get the model discovery endpoint."""
return cls._select_endpoint(
UiPathEndpoints.AH_DISCOVERY_ENDPOINT,
UiPathEndpoints.OR_DISCOVERY_ENDPOINT,
)

@classmethod
def get_embeddings_endpoint(cls) -> str:
"""Get the embeddings endpoint."""
Expand Down
Loading
Loading