feat(ai): handle temperature incompatibility for gpt-5 and o-series models, add model picker UI

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-02-23 22:12:08 +00:00
parent 2eecfd0d4b
commit a94b52ee14
4 changed files with 234 additions and 51 deletions
+91 -49
View File
@@ -11,6 +11,7 @@ See the Configuration Guide for full details on each provider's settings.
""" """
import logging import logging
import re
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@@ -19,6 +20,50 @@ from app.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _resolve_temperature(model: str, requested: float) -> Optional[float]:
"""Return a temperature value compatible with the given model, or ``None`` to omit it.
Certain model families have restrictions on the ``temperature`` parameter:
* **o-series reasoning models** (``o1``, ``o3``, ``o4``, …) do not accept
a ``temperature`` argument at all. Return ``None`` so callers can skip the
parameter entirely.
* **gpt-5 family** (``gpt-5``, ``gpt-5-nano``, ``gpt-5-codex``, …) only
``temperature=1`` is accepted; passing ``0`` raises a 400 error. Return
``1`` and emit a debug log so the caller is aware of the coercion.
* All other models return the requested value unchanged.
The model string may include a provider prefix (e.g. ``openai/gpt-4o``);
only the part after the last ``/`` is examined.
Args:
model: Model identifier (may include a provider prefix).
requested: The temperature the caller wants to use.
Returns:
A compatible temperature float, or ``None`` if temperature should be
omitted from the API call.
"""
bare = model.lower().split("/")[-1]
# o-series reasoning models (o1, o3, o4 …) do not support temperature
if re.match(r"^o\d+(-|$)", bare):
logger.debug("Dropping temperature parameter for reasoning model '%s' (not supported)", model)
return None
# gpt-5 family only supports temperature=1
if bare.startswith("gpt-5"):
if requested != 1.0:
logger.debug(
"Coercing temperature from %s to 1 for model '%s' (only temperature=1 is supported)",
requested,
model,
)
return 1.0
return requested
def _require_text_content(content: Optional[str]) -> str: def _require_text_content(content: Optional[str]) -> str:
"""Raise a clear error if the AI response contains no text content. """Raise a clear error if the AI response contains no text content.
@@ -101,12 +146,12 @@ class OpenAIProvider(AIProvider):
temperature: float = 0, temperature: float = 0,
**kwargs: Any, **kwargs: Any,
) -> str: ) -> str:
completion = self._client.chat.completions.create( call_kwargs: Dict[str, Any] = {"model": model, "messages": messages}
model=model, safe_temp = _resolve_temperature(model, temperature)
messages=messages, if safe_temp is not None:
temperature=temperature, call_kwargs["temperature"] = safe_temp
**kwargs, call_kwargs.update(kwargs)
) completion = self._client.chat.completions.create(**call_kwargs)
_content = completion.choices[0].message.content _content = completion.choices[0].message.content
return _require_text_content(_content) return _require_text_content(_content)
@@ -130,12 +175,12 @@ class AzureOpenAIProvider(AIProvider):
temperature: float = 0, temperature: float = 0,
**kwargs: Any, **kwargs: Any,
) -> str: ) -> str:
completion = self._client.chat.completions.create( call_kwargs: Dict[str, Any] = {"model": model, "messages": messages}
model=model, safe_temp = _resolve_temperature(model, temperature)
messages=messages, if safe_temp is not None:
temperature=temperature, call_kwargs["temperature"] = safe_temp
**kwargs, call_kwargs.update(kwargs)
) completion = self._client.chat.completions.create(**call_kwargs)
_content = completion.choices[0].message.content _content = completion.choices[0].message.content
return _require_text_content(_content) return _require_text_content(_content)
@@ -161,13 +206,12 @@ class AnthropicProvider(AIProvider):
import litellm import litellm
model_name = model if model.startswith("anthropic/") else f"anthropic/{model}" model_name = model if model.startswith("anthropic/") else f"anthropic/{model}"
response = litellm.completion( call_kwargs: Dict[str, Any] = {"model": model_name, "messages": messages, "api_key": self._api_key}
model=model_name, safe_temp = _resolve_temperature(model, temperature)
messages=messages, if safe_temp is not None:
temperature=temperature, call_kwargs["temperature"] = safe_temp
api_key=self._api_key, call_kwargs.update(kwargs)
**kwargs, response = litellm.completion(**call_kwargs)
)
_content = response.choices[0].message.content _content = response.choices[0].message.content
return _require_text_content(_content) return _require_text_content(_content)
@@ -193,13 +237,12 @@ class GeminiProvider(AIProvider):
import litellm import litellm
model_name = model if model.startswith("gemini/") else f"gemini/{model}" model_name = model if model.startswith("gemini/") else f"gemini/{model}"
response = litellm.completion( call_kwargs: Dict[str, Any] = {"model": model_name, "messages": messages, "api_key": self._api_key}
model=model_name, safe_temp = _resolve_temperature(model, temperature)
messages=messages, if safe_temp is not None:
temperature=temperature, call_kwargs["temperature"] = safe_temp
api_key=self._api_key, call_kwargs.update(kwargs)
**kwargs, response = litellm.completion(**call_kwargs)
)
_content = response.choices[0].message.content _content = response.choices[0].message.content
return _require_text_content(_content) return _require_text_content(_content)
@@ -235,12 +278,12 @@ class OllamaProvider(AIProvider):
temperature: float = 0, temperature: float = 0,
**kwargs: Any, **kwargs: Any,
) -> str: ) -> str:
completion = self._client.chat.completions.create( call_kwargs: Dict[str, Any] = {"model": model, "messages": messages}
model=model, safe_temp = _resolve_temperature(model, temperature)
messages=messages, if safe_temp is not None:
temperature=temperature, call_kwargs["temperature"] = safe_temp
**kwargs, call_kwargs.update(kwargs)
) completion = self._client.chat.completions.create(**call_kwargs)
_content = completion.choices[0].message.content _content = completion.choices[0].message.content
return _require_text_content(_content) return _require_text_content(_content)
@@ -269,12 +312,12 @@ class OpenRouterProvider(AIProvider):
temperature: float = 0, temperature: float = 0,
**kwargs: Any, **kwargs: Any,
) -> str: ) -> str:
completion = self._client.chat.completions.create( call_kwargs: Dict[str, Any] = {"model": model, "messages": messages}
model=model, safe_temp = _resolve_temperature(model, temperature)
messages=messages, if safe_temp is not None:
temperature=temperature, call_kwargs["temperature"] = safe_temp
**kwargs, call_kwargs.update(kwargs)
) completion = self._client.chat.completions.create(**call_kwargs)
_content = completion.choices[0].message.content _content = completion.choices[0].message.content
return _require_text_content(_content) return _require_text_content(_content)
@@ -335,12 +378,12 @@ class PortkeyProvider(AIProvider):
temperature: float = 0, temperature: float = 0,
**kwargs: Any, **kwargs: Any,
) -> str: ) -> str:
completion = self._client.chat.completions.create( call_kwargs: Dict[str, Any] = {"model": model, "messages": messages}
model=model, safe_temp = _resolve_temperature(model, temperature)
messages=messages, if safe_temp is not None:
temperature=temperature, call_kwargs["temperature"] = safe_temp
**kwargs, call_kwargs.update(kwargs)
) completion = self._client.chat.completions.create(**call_kwargs)
_content = completion.choices[0].message.content _content = completion.choices[0].message.content
return _require_text_content(_content) return _require_text_content(_content)
@@ -371,11 +414,10 @@ class LiteLLMProvider(AIProvider):
) -> str: ) -> str:
import litellm import litellm
completion_kwargs: Dict[str, Any] = { completion_kwargs: Dict[str, Any] = {"model": model, "messages": messages}
"model": model, safe_temp = _resolve_temperature(model, temperature)
"messages": messages, if safe_temp is not None:
"temperature": temperature, completion_kwargs["temperature"] = safe_temp
}
if self._api_key: if self._api_key:
completion_kwargs["api_key"] = self._api_key completion_kwargs["api_key"] = self._api_key
if self._api_base: if self._api_base:
+48 -2
View File
@@ -153,10 +153,33 @@ SETTING_METADATA = {
"openai_model": { "openai_model": {
"category": "AI Services", "category": "AI Services",
"description": "Fallback model name used when AI_MODEL is not set (e.g. gpt-4o-mini)", "description": "Fallback model name used when AI_MODEL is not set (e.g. gpt-4o-mini)",
"type": "string", "type": "model_picker",
"sensitive": False, "sensitive": False,
"required": False, "required": False,
"restart_required": False, "restart_required": False,
"suggested_models": [
"gpt-4o",
"gpt-4o-mini",
"gpt-4-turbo",
"gpt-4",
"gpt-3.5-turbo",
"o1",
"o1-mini",
"o3",
"o3-mini",
"gpt-5",
"gpt-5-nano",
"claude-3-5-sonnet-20241022",
"claude-3-5-haiku-20241022",
"claude-3-opus-20240229",
"gemini-1.5-pro",
"gemini-1.5-flash",
"gemini-2.0-flash-exp",
"llama3.2",
"qwen2.5:7b",
"phi3",
"mistral",
],
}, },
"ai_provider": { "ai_provider": {
"category": "AI Services", "category": "AI Services",
@@ -170,10 +193,33 @@ SETTING_METADATA = {
"ai_model": { "ai_model": {
"category": "AI Services", "category": "AI Services",
"description": "Model name for the selected provider (overrides OPENAI_MODEL). E.g. gpt-4o, claude-3-5-sonnet-20241022, gemini-1.5-pro, llama3.2", "description": "Model name for the selected provider (overrides OPENAI_MODEL). E.g. gpt-4o, claude-3-5-sonnet-20241022, gemini-1.5-pro, llama3.2",
"type": "string", "type": "model_picker",
"sensitive": False, "sensitive": False,
"required": False, "required": False,
"restart_required": False, "restart_required": False,
"suggested_models": [
"gpt-4o",
"gpt-4o-mini",
"gpt-4-turbo",
"gpt-4",
"gpt-3.5-turbo",
"o1",
"o1-mini",
"o3",
"o3-mini",
"gpt-5",
"gpt-5-nano",
"claude-3-5-sonnet-20241022",
"claude-3-5-haiku-20241022",
"claude-3-opus-20240229",
"gemini-1.5-pro",
"gemini-1.5-flash",
"gemini-2.0-flash-exp",
"llama3.2",
"qwen2.5:7b",
"phi3",
"mistral",
],
}, },
"anthropic_api_key": { "anthropic_api_key": {
"category": "AI Services", "category": "AI Services",
+23
View File
@@ -139,6 +139,29 @@
Enable {{ setting.key.replace('_', ' ').title() }} Enable {{ setting.key.replace('_', ' ').title() }}
</label> </label>
</div> </div>
{% elif setting.metadata.type == 'model_picker' %}
<!-- Model Picker: free-text input with datalist of common models -->
<div class="relative">
<input
type="text"
id="{{ setting.key }}"
name="{{ setting.key }}"
list="{{ setting.key }}_models"
x-model="formData['{{ setting.key }}']"
class="setting-input w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
placeholder="Select a common model or type a custom name…"
autocomplete="off"
/>
<datalist id="{{ setting.key }}_models">
{% for m in setting.metadata.suggested_models %}
<option value="{{ m }}">{{ m }}</option>
{% endfor %}
</datalist>
<p class="text-xs text-gray-400 mt-1">
<i class="fas fa-info-circle mr-1"></i>
Pick from the list or type any model name supported by your provider.
</p>
</div>
{% elif setting.metadata.options %} {% elif setting.metadata.options %}
<!-- Dropdown Select for fields with a fixed list of values --> <!-- Dropdown Select for fields with a fixed list of values -->
<select <select
+72
View File
@@ -16,6 +16,7 @@ from app.utils.ai_provider import (
OpenRouterProvider, OpenRouterProvider,
PortkeyProvider, PortkeyProvider,
_require_text_content, _require_text_content,
_resolve_temperature,
get_ai_provider, get_ai_provider,
) )
@@ -74,6 +75,77 @@ class TestRequireTextContent:
_require_text_content(None) _require_text_content(None)
# ---------------------------------------------------------------------------
# _resolve_temperature helper
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestResolveTemperature:
"""Tests for the _resolve_temperature compatibility helper."""
def test_regular_model_returns_requested_temperature(self):
"""Standard models return the temperature unchanged."""
assert _resolve_temperature("gpt-4o", 0) == 0
assert _resolve_temperature("gpt-4o-mini", 0.7) == 0.7
def test_gpt4_model_returns_requested_temperature(self):
"""gpt-4 models are not gpt-5, so temperature is returned as-is."""
assert _resolve_temperature("gpt-4-turbo", 0) == 0
def test_gpt5_model_forces_temperature_1(self):
"""gpt-5 models only accept temperature=1; any other value is coerced."""
assert _resolve_temperature("gpt-5", 0) == 1.0
def test_gpt5_nano_forces_temperature_1(self):
"""gpt-5-nano (gpt-5 variant) gets temperature coerced to 1."""
assert _resolve_temperature("gpt-5-nano", 0) == 1.0
def test_gpt5_codex_forces_temperature_1(self):
"""gpt-5-codex (gpt-5 variant) gets temperature coerced to 1."""
assert _resolve_temperature("gpt-5-codex", 0) == 1.0
def test_gpt5_already_at_1_unchanged(self):
"""gpt-5 with temperature=1 returns 1 (no unnecessary log noise)."""
assert _resolve_temperature("gpt-5", 1.0) == 1.0
def test_o1_returns_none(self):
"""o1 reasoning model does not support temperature; None is returned."""
assert _resolve_temperature("o1", 0) is None
def test_o1_mini_returns_none(self):
"""o1-mini returns None (temperature not supported)."""
assert _resolve_temperature("o1-mini", 0) is None
def test_o1_preview_returns_none(self):
"""o1-preview returns None (temperature not supported)."""
assert _resolve_temperature("o1-preview", 0) is None
def test_o3_returns_none(self):
"""o3 reasoning model does not support temperature."""
assert _resolve_temperature("o3", 0) is None
def test_o3_mini_returns_none(self):
"""o3-mini returns None (temperature not supported)."""
assert _resolve_temperature("o3-mini", 0) is None
def test_o4_mini_returns_none(self):
"""o4-mini returns None (temperature not supported)."""
assert _resolve_temperature("o4-mini", 0) is None
def test_provider_prefix_is_stripped(self):
"""Provider prefix (e.g. 'openai/') is ignored when matching."""
assert _resolve_temperature("openai/gpt-5-nano", 0) == 1.0
assert _resolve_temperature("openai/o1-mini", 0) is None
assert _resolve_temperature("openai/gpt-4o", 0) == 0
def test_model_names_are_case_insensitive(self):
"""Model matching is case-insensitive."""
assert _resolve_temperature("GPT-5", 0) == 1.0
assert _resolve_temperature("O1", 0) is None
assert _resolve_temperature("O3-Mini", 0) is None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Abstract base class # Abstract base class
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------