fix(qwen): support reasoning-model params (max_completion_tokens, omit temperature) (#128)

The qwen_chat backend (the generic OpenAI-compatible client) hardcoded
max_tokens and always sent temperature, so reasoning models behind
OpenAI-compatible gateways (GPT-5.x, Claude Opus 4.8 via Azure/LiteLLM)
would 400.

- Add opt-in QWEN_CHAT_USE_MAX_COMPLETION_TOKENS (+ role variants) that
  swaps the payload key max_tokens -> max_completion_tokens.
- Treat an explicit empty / none / off temperature as "omit" instead of
  collapsing to the 0.7 default (via _resolve_temperature).
- Thread both through configure_qwen_chat / _update_config.
- Defaults unchanged; fully backward compatible. Adds 6 tests.

Fixes #127

Co-authored-by: Chirag Singhal <chirag127@users.noreply.github.com>
This commit is contained in:
Chirag Singhal
2026-07-12 21:54:40 +05:30
committed by GitHub
parent b309723baa
commit 46e8e800bc
3 changed files with 144 additions and 39 deletions
+9 -3
View File
@@ -13,13 +13,13 @@ from skillopt.model.backend_config import ( # noqa: F401
configure_codex_exec, configure_codex_exec,
get_claude_code_exec_config, get_claude_code_exec_config,
get_codex_exec_config, get_codex_exec_config,
get_target_backend,
get_optimizer_backend, get_optimizer_backend,
get_target_backend,
is_optimizer_chat_backend,
is_target_chat_backend, is_target_chat_backend,
is_target_exec_backend, is_target_exec_backend,
is_optimizer_chat_backend,
set_target_backend,
set_optimizer_backend, set_optimizer_backend,
set_target_backend,
) )
@@ -440,18 +440,21 @@ def configure_qwen_chat(
timeout_seconds: float | str | None = None, timeout_seconds: float | str | None = None,
max_tokens: int | str | None = None, max_tokens: int | str | None = None,
enable_thinking: bool | str | None = None, enable_thinking: bool | str | None = None,
use_max_completion_tokens: bool | str | None = None,
optimizer_base_url: str | None = None, optimizer_base_url: str | None = None,
optimizer_api_key: str | None = None, optimizer_api_key: str | None = None,
optimizer_temperature: float | str | None = None, optimizer_temperature: float | str | None = None,
optimizer_timeout_seconds: float | str | None = None, optimizer_timeout_seconds: float | str | None = None,
optimizer_max_tokens: int | str | None = None, optimizer_max_tokens: int | str | None = None,
optimizer_enable_thinking: bool | str | None = None, optimizer_enable_thinking: bool | str | None = None,
optimizer_use_max_completion_tokens: bool | str | None = None,
target_base_url: str | None = None, target_base_url: str | None = None,
target_api_key: str | None = None, target_api_key: str | None = None,
target_temperature: float | str | None = None, target_temperature: float | str | None = None,
target_timeout_seconds: float | str | None = None, target_timeout_seconds: float | str | None = None,
target_max_tokens: int | str | None = None, target_max_tokens: int | str | None = None,
target_enable_thinking: bool | str | None = None, target_enable_thinking: bool | str | None = None,
target_use_max_completion_tokens: bool | str | None = None,
) -> None: ) -> None:
_qwen.configure_qwen_chat( _qwen.configure_qwen_chat(
base_url=base_url, base_url=base_url,
@@ -460,18 +463,21 @@ def configure_qwen_chat(
timeout_seconds=timeout_seconds, timeout_seconds=timeout_seconds,
max_tokens=max_tokens, max_tokens=max_tokens,
enable_thinking=enable_thinking, enable_thinking=enable_thinking,
use_max_completion_tokens=use_max_completion_tokens,
optimizer_base_url=optimizer_base_url, optimizer_base_url=optimizer_base_url,
optimizer_api_key=optimizer_api_key, optimizer_api_key=optimizer_api_key,
optimizer_temperature=optimizer_temperature, optimizer_temperature=optimizer_temperature,
optimizer_timeout_seconds=optimizer_timeout_seconds, optimizer_timeout_seconds=optimizer_timeout_seconds,
optimizer_max_tokens=optimizer_max_tokens, optimizer_max_tokens=optimizer_max_tokens,
optimizer_enable_thinking=optimizer_enable_thinking, optimizer_enable_thinking=optimizer_enable_thinking,
optimizer_use_max_completion_tokens=optimizer_use_max_completion_tokens,
target_base_url=target_base_url, target_base_url=target_base_url,
target_api_key=target_api_key, target_api_key=target_api_key,
target_temperature=target_temperature, target_temperature=target_temperature,
target_timeout_seconds=target_timeout_seconds, target_timeout_seconds=target_timeout_seconds,
target_max_tokens=target_max_tokens, target_max_tokens=target_max_tokens,
target_enable_thinking=target_enable_thinking, target_enable_thinking=target_enable_thinking,
target_use_max_completion_tokens=target_use_max_completion_tokens,
) )
+60 -32
View File
@@ -1,13 +1,14 @@
"""OpenAI-compatible Qwen chat backend for optimizer and target paths.""" """OpenAI-compatible Qwen chat backend for optimizer and target paths."""
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
import json import json
import os import os
import threading import threading
import time import time
import urllib.error import urllib.error
import urllib.request import urllib.request
from dataclasses import dataclass
from typing import Any from typing import Any
from skillopt.model.common import ( from skillopt.model.common import (
@@ -28,6 +29,7 @@ class QwenChatConfig:
temperature: float | None temperature: float | None
enable_thinking: bool enable_thinking: bool
deployment: str deployment: str
use_max_completion_tokens: bool = False
def _parse_bool(value: Any, default: bool = False) -> bool: def _parse_bool(value: Any, default: bool = False) -> bool:
@@ -56,6 +58,28 @@ def _role_env(role: str, key: str, default: str) -> str:
return os.environ.get(role_key) or os.environ.get(generic_key) or default return os.environ.get(role_key) or os.environ.get(generic_key) or default
# Sentinels that mean "omit this optional parameter from the request payload".
# Reasoning models (e.g. GPT-5.x, Claude Opus 4.8) reject an explicit
# `temperature`, so allow it to be turned off via an empty string / none / off.
_OMIT_SENTINELS = {"", "none", "off", "null"}
def _resolve_temperature(role: str) -> float | None:
"""Return the temperature, or None to omit it entirely.
Unlike ``_role_env`` an *explicitly set* empty (or ``none``/``off``) value is
honored as "omit" instead of collapsing to the default. Precedence:
role-specific env -> generic env -> 0.7 default.
"""
for key in (f"{role.upper()}_QWEN_CHAT_TEMPERATURE", "QWEN_CHAT_TEMPERATURE"):
if key in os.environ:
raw = os.environ[key].strip()
if raw.lower() in _OMIT_SENTINELS:
return None
return float(raw)
return 0.7
def _initial_config(role: str) -> QwenChatConfig: def _initial_config(role: str) -> QwenChatConfig:
role_upper = role.upper() role_upper = role.upper()
deployment_env = "OPTIMIZER_DEPLOYMENT" if role == "optimizer" else "TARGET_DEPLOYMENT" deployment_env = "OPTIMIZER_DEPLOYMENT" if role == "optimizer" else "TARGET_DEPLOYMENT"
@@ -64,8 +88,9 @@ def _initial_config(role: str) -> QwenChatConfig:
api_key=_role_env(role, "API_KEY", ""), api_key=_role_env(role, "API_KEY", ""),
timeout_seconds=float(_role_env(role, "TIMEOUT_SECONDS", "300") or 300), timeout_seconds=float(_role_env(role, "TIMEOUT_SECONDS", "300") or 300),
max_tokens=_parse_int(_role_env(role, "MAX_TOKENS", "8000"), 8000), max_tokens=_parse_int(_role_env(role, "MAX_TOKENS", "8000"), 8000),
temperature=_parse_optional_float(_role_env(role, "TEMPERATURE", "0.7")), temperature=_resolve_temperature(role),
enable_thinking=_parse_bool(_role_env(role, "ENABLE_THINKING", "false")), enable_thinking=_parse_bool(_role_env(role, "ENABLE_THINKING", "false")),
use_max_completion_tokens=_parse_bool(_role_env(role, "USE_MAX_COMPLETION_TOKENS", "false")),
deployment=( deployment=(
os.environ.get(f"{role_upper}_QWEN_CHAT_MODEL") os.environ.get(f"{role_upper}_QWEN_CHAT_MODEL")
or os.environ.get("QWEN_CHAT_MODEL") or os.environ.get("QWEN_CHAT_MODEL")
@@ -186,10 +211,14 @@ def _chat_messages_impl(
timeout: float | None = None, timeout: float | None = None,
) -> tuple[Any, dict[str, int]]: ) -> tuple[Any, dict[str, int]]:
config = OPTIMIZER_CONFIG if role == "optimizer" else TARGET_CONFIG config = OPTIMIZER_CONFIG if role == "optimizer" else TARGET_CONFIG
token_limit = min(max_completion_tokens, config.max_tokens)
# Reasoning models on some gateways (GPT-5.x, o-series) require
# `max_completion_tokens` and reject the legacy `max_tokens`.
token_key = "max_completion_tokens" if config.use_max_completion_tokens else "max_tokens"
payload: dict[str, Any] = { payload: dict[str, Any] = {
"model": deployment or config.deployment, "model": deployment or config.deployment,
"messages": _json_safe(messages), "messages": _json_safe(messages),
"max_tokens": min(max_completion_tokens, config.max_tokens), token_key: token_limit,
} }
if config.enable_thinking: if config.enable_thinking:
payload["chat_template_kwargs"] = {"enable_thinking": True} payload["chat_template_kwargs"] = {"enable_thinking": True}
@@ -219,7 +248,7 @@ def _chat_messages_impl(
return text, usage_info return text, usage_info
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
last_err = e last_err = e
time.sleep(min(2 ** attempt, 30)) time.sleep(min(2**attempt, 30))
raise RuntimeError(f"Qwen chat call failed after {retries} retries: {last_err}") raise RuntimeError(f"Qwen chat call failed after {retries} retries: {last_err}")
@@ -231,18 +260,21 @@ def configure_qwen_chat(
timeout_seconds: float | str | None = None, timeout_seconds: float | str | None = None,
max_tokens: int | str | None = None, max_tokens: int | str | None = None,
enable_thinking: bool | str | None = None, enable_thinking: bool | str | None = None,
use_max_completion_tokens: bool | str | None = None,
optimizer_base_url: str | None = None, optimizer_base_url: str | None = None,
optimizer_api_key: str | None = None, optimizer_api_key: str | None = None,
optimizer_temperature: float | str | None = None, optimizer_temperature: float | str | None = None,
optimizer_timeout_seconds: float | str | None = None, optimizer_timeout_seconds: float | str | None = None,
optimizer_max_tokens: int | str | None = None, optimizer_max_tokens: int | str | None = None,
optimizer_enable_thinking: bool | str | None = None, optimizer_enable_thinking: bool | str | None = None,
optimizer_use_max_completion_tokens: bool | str | None = None,
target_base_url: str | None = None, target_base_url: str | None = None,
target_api_key: str | None = None, target_api_key: str | None = None,
target_temperature: float | str | None = None, target_temperature: float | str | None = None,
target_timeout_seconds: float | str | None = None, target_timeout_seconds: float | str | None = None,
target_max_tokens: int | str | None = None, target_max_tokens: int | str | None = None,
target_enable_thinking: bool | str | None = None, target_enable_thinking: bool | str | None = None,
target_use_max_completion_tokens: bool | str | None = None,
) -> None: ) -> None:
with _config_lock: with _config_lock:
if base_url is not None: if base_url is not None:
@@ -256,29 +288,24 @@ def configure_qwen_chat(
if max_tokens is not None: if max_tokens is not None:
os.environ["QWEN_CHAT_MAX_TOKENS"] = str(max_tokens) os.environ["QWEN_CHAT_MAX_TOKENS"] = str(max_tokens)
if enable_thinking is not None: if enable_thinking is not None:
os.environ["QWEN_CHAT_ENABLE_THINKING"] = ( os.environ["QWEN_CHAT_ENABLE_THINKING"] = "true" if _parse_bool(enable_thinking) else "false"
"true" if _parse_bool(enable_thinking) else "false" if use_max_completion_tokens is not None:
os.environ["QWEN_CHAT_USE_MAX_COMPLETION_TOKENS"] = (
"true" if _parse_bool(use_max_completion_tokens) else "false"
) )
_update_config( _update_config(
OPTIMIZER_CONFIG, OPTIMIZER_CONFIG,
"optimizer", "optimizer",
base_url=optimizer_base_url if optimizer_base_url is not None else base_url, base_url=optimizer_base_url if optimizer_base_url is not None else base_url,
api_key=optimizer_api_key if optimizer_api_key is not None else api_key, api_key=optimizer_api_key if optimizer_api_key is not None else api_key,
temperature=( temperature=(optimizer_temperature if optimizer_temperature is not None else temperature),
optimizer_temperature timeout_seconds=(optimizer_timeout_seconds if optimizer_timeout_seconds is not None else timeout_seconds),
if optimizer_temperature is not None
else temperature
),
timeout_seconds=(
optimizer_timeout_seconds
if optimizer_timeout_seconds is not None
else timeout_seconds
),
max_tokens=optimizer_max_tokens if optimizer_max_tokens is not None else max_tokens, max_tokens=optimizer_max_tokens if optimizer_max_tokens is not None else max_tokens,
enable_thinking=( enable_thinking=(optimizer_enable_thinking if optimizer_enable_thinking is not None else enable_thinking),
optimizer_enable_thinking use_max_completion_tokens=(
if optimizer_enable_thinking is not None optimizer_use_max_completion_tokens
else enable_thinking if optimizer_use_max_completion_tokens is not None
else use_max_completion_tokens
), ),
) )
_update_config( _update_config(
@@ -287,16 +314,13 @@ def configure_qwen_chat(
base_url=target_base_url if target_base_url is not None else base_url, base_url=target_base_url if target_base_url is not None else base_url,
api_key=target_api_key if target_api_key is not None else api_key, api_key=target_api_key if target_api_key is not None else api_key,
temperature=target_temperature if target_temperature is not None else temperature, temperature=target_temperature if target_temperature is not None else temperature,
timeout_seconds=( timeout_seconds=(target_timeout_seconds if target_timeout_seconds is not None else timeout_seconds),
target_timeout_seconds
if target_timeout_seconds is not None
else timeout_seconds
),
max_tokens=target_max_tokens if target_max_tokens is not None else max_tokens, max_tokens=target_max_tokens if target_max_tokens is not None else max_tokens,
enable_thinking=( enable_thinking=(target_enable_thinking if target_enable_thinking is not None else enable_thinking),
target_enable_thinking use_max_completion_tokens=(
if target_enable_thinking is not None target_use_max_completion_tokens
else enable_thinking if target_use_max_completion_tokens is not None
else use_max_completion_tokens
), ),
) )
@@ -311,6 +335,7 @@ def _update_config(
timeout_seconds: float | str | None = None, timeout_seconds: float | str | None = None,
max_tokens: int | str | None = None, max_tokens: int | str | None = None,
enable_thinking: bool | str | None = None, enable_thinking: bool | str | None = None,
use_max_completion_tokens: bool | str | None = None,
) -> None: ) -> None:
env_prefix = role.upper() env_prefix = role.upper()
if base_url is not None: if base_url is not None:
@@ -321,7 +346,7 @@ def _update_config(
os.environ[f"{env_prefix}_QWEN_CHAT_API_KEY"] = config.api_key os.environ[f"{env_prefix}_QWEN_CHAT_API_KEY"] = config.api_key
if temperature is not None: if temperature is not None:
raw = str(temperature).strip() raw = str(temperature).strip()
config.temperature = float(raw) if raw else None config.temperature = None if raw.lower() in _OMIT_SENTINELS else float(raw)
os.environ[f"{env_prefix}_QWEN_CHAT_TEMPERATURE"] = raw os.environ[f"{env_prefix}_QWEN_CHAT_TEMPERATURE"] = raw
if timeout_seconds is not None: if timeout_seconds is not None:
config.timeout_seconds = float(timeout_seconds) config.timeout_seconds = float(timeout_seconds)
@@ -331,8 +356,11 @@ def _update_config(
os.environ[f"{env_prefix}_QWEN_CHAT_MAX_TOKENS"] = str(max_tokens) os.environ[f"{env_prefix}_QWEN_CHAT_MAX_TOKENS"] = str(max_tokens)
if enable_thinking is not None: if enable_thinking is not None:
config.enable_thinking = _parse_bool(enable_thinking) config.enable_thinking = _parse_bool(enable_thinking)
os.environ[f"{env_prefix}_QWEN_CHAT_ENABLE_THINKING"] = ( os.environ[f"{env_prefix}_QWEN_CHAT_ENABLE_THINKING"] = "true" if config.enable_thinking else "false"
"true" if config.enable_thinking else "false" if use_max_completion_tokens is not None:
config.use_max_completion_tokens = _parse_bool(use_max_completion_tokens)
os.environ[f"{env_prefix}_QWEN_CHAT_USE_MAX_COMPLETION_TOKENS"] = (
"true" if config.use_max_completion_tokens else "false"
) )
+75 -4
View File
@@ -1,4 +1,5 @@
"""Tests for the OpenAI-compatible Qwen chat backend.""" """Tests for the OpenAI-compatible Qwen chat backend."""
from __future__ import annotations from __future__ import annotations
import importlib.util import importlib.util
@@ -14,7 +15,6 @@ import pytest
from skillopt.envs.searchqa.evaluator import extract_answer from skillopt.envs.searchqa.evaluator import extract_answer
_QWEN_CONFIG_ENV_KEYS = ( _QWEN_CONFIG_ENV_KEYS = (
"BASE_URL", "BASE_URL",
"API_KEY", "API_KEY",
@@ -22,11 +22,10 @@ _QWEN_CONFIG_ENV_KEYS = (
"TIMEOUT_SECONDS", "TIMEOUT_SECONDS",
"MAX_TOKENS", "MAX_TOKENS",
"ENABLE_THINKING", "ENABLE_THINKING",
"USE_MAX_COMPLETION_TOKENS",
) )
_ENV_KEYS = ("OPTIMIZER_BACKEND", "TARGET_BACKEND") + tuple( _ENV_KEYS = ("OPTIMIZER_BACKEND", "TARGET_BACKEND") + tuple(
f"{prefix}QWEN_CHAT_{key}" f"{prefix}QWEN_CHAT_{key}" for prefix in ("", "OPTIMIZER_", "TARGET_") for key in _QWEN_CONFIG_ENV_KEYS
for prefix in ("", "OPTIMIZER_", "TARGET_")
for key in _QWEN_CONFIG_ENV_KEYS
) )
@@ -225,3 +224,75 @@ def test_configure_qwen_chat_runtime_toggle_controls_payload(
assert recorder.calls[0]["payload"]["chat_template_kwargs"] == {"enable_thinking": True} assert recorder.calls[0]["payload"]["chat_template_kwargs"] == {"enable_thinking": True}
assert "chat_template_kwargs" not in recorder.calls[1]["payload"] assert "chat_template_kwargs" not in recorder.calls[1]["payload"]
def test_chat_target_uses_max_tokens_by_default(
monkeypatch: pytest.MonkeyPatch,
isolate_qwen_state: tuple[Any, Any],
) -> None:
model_module, qwen_backend = isolate_qwen_state
_use_qwen_target(model_module, qwen_backend, enable_thinking=False)
recorder = _record_urlopen(monkeypatch, qwen_backend)
model_module.chat_target("system", "user", max_completion_tokens=128, retries=1)
payload = recorder.calls[0]["payload"]
assert payload["max_tokens"] == 128
assert "max_completion_tokens" not in payload
def test_chat_target_uses_max_completion_tokens_when_enabled(
monkeypatch: pytest.MonkeyPatch,
isolate_qwen_state: tuple[Any, Any],
) -> None:
model_module, qwen_backend = isolate_qwen_state
_use_qwen_target(model_module, qwen_backend, enable_thinking=False)
qwen_backend.TARGET_CONFIG.use_max_completion_tokens = True
recorder = _record_urlopen(monkeypatch, qwen_backend)
model_module.chat_target("system", "user", max_completion_tokens=128, retries=1)
payload = recorder.calls[0]["payload"]
assert payload["max_completion_tokens"] == 128
assert "max_tokens" not in payload
def test_configure_qwen_chat_toggles_max_completion_tokens(
monkeypatch: pytest.MonkeyPatch,
isolate_qwen_state: tuple[Any, Any],
) -> None:
model_module, qwen_backend = isolate_qwen_state
_use_qwen_target(model_module, qwen_backend, enable_thinking=False)
recorder = _record_urlopen(monkeypatch, qwen_backend)
model_module.configure_qwen_chat(target_use_max_completion_tokens=True)
model_module.chat_target("system", "user", max_completion_tokens=128, retries=1)
model_module.configure_qwen_chat(target_use_max_completion_tokens=False)
model_module.chat_target("system", "user", max_completion_tokens=128, retries=1)
assert "max_completion_tokens" in recorder.calls[0]["payload"]
assert "max_tokens" in recorder.calls[1]["payload"]
def test_temperature_omitted_when_env_is_blank(
monkeypatch: pytest.MonkeyPatch,
isolate_qwen_state: tuple[Any, Any],
) -> None:
_model_module, qwen_backend = isolate_qwen_state
# Explicit blank means "omit", not "fall back to 0.7".
monkeypatch.setenv("TARGET_QWEN_CHAT_TEMPERATURE", "")
assert qwen_backend._resolve_temperature("target") is None
monkeypatch.setenv("TARGET_QWEN_CHAT_TEMPERATURE", "off")
assert qwen_backend._resolve_temperature("target") is None
def test_temperature_resolves_float_and_default(
monkeypatch: pytest.MonkeyPatch,
isolate_qwen_state: tuple[Any, Any],
) -> None:
_model_module, qwen_backend = isolate_qwen_state
monkeypatch.delenv("QWEN_CHAT_TEMPERATURE", raising=False)
monkeypatch.delenv("TARGET_QWEN_CHAT_TEMPERATURE", raising=False)
assert qwen_backend._resolve_temperature("target") == 0.7
monkeypatch.setenv("TARGET_QWEN_CHAT_TEMPERATURE", "0.2")
assert qwen_backend._resolve_temperature("target") == 0.2