Skip to content

Commit 46e8e80

Browse files
authored
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>
1 parent b309723 commit 46e8e80

3 files changed

Lines changed: 144 additions & 39 deletions

File tree

‎skillopt/model/__init__.py‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,13 @@
1313
configure_codex_exec,
1414
get_claude_code_exec_config,
1515
get_codex_exec_config,
16-
get_target_backend,
1716
get_optimizer_backend,
17+
get_target_backend,
18+
is_optimizer_chat_backend,
1819
is_target_chat_backend,
1920
is_target_exec_backend,
20-
is_optimizer_chat_backend,
21-
set_target_backend,
2221
set_optimizer_backend,
22+
set_target_backend,
2323
)
2424

2525

@@ -440,18 +440,21 @@ def configure_qwen_chat(
440440
timeout_seconds: float | str | None = None,
441441
max_tokens: int | str | None = None,
442442
enable_thinking: bool | str | None = None,
443+
use_max_completion_tokens: bool | str | None = None,
443444
optimizer_base_url: str | None = None,
444445
optimizer_api_key: str | None = None,
445446
optimizer_temperature: float | str | None = None,
446447
optimizer_timeout_seconds: float | str | None = None,
447448
optimizer_max_tokens: int | str | None = None,
448449
optimizer_enable_thinking: bool | str | None = None,
450+
optimizer_use_max_completion_tokens: bool | str | None = None,
449451
target_base_url: str | None = None,
450452
target_api_key: str | None = None,
451453
target_temperature: float | str | None = None,
452454
target_timeout_seconds: float | str | None = None,
453455
target_max_tokens: int | str | None = None,
454456
target_enable_thinking: bool | str | None = None,
457+
target_use_max_completion_tokens: bool | str | None = None,
455458
) -> None:
456459
_qwen.configure_qwen_chat(
457460
base_url=base_url,
@@ -460,18 +463,21 @@ def configure_qwen_chat(
460463
timeout_seconds=timeout_seconds,
461464
max_tokens=max_tokens,
462465
enable_thinking=enable_thinking,
466+
use_max_completion_tokens=use_max_completion_tokens,
463467
optimizer_base_url=optimizer_base_url,
464468
optimizer_api_key=optimizer_api_key,
465469
optimizer_temperature=optimizer_temperature,
466470
optimizer_timeout_seconds=optimizer_timeout_seconds,
467471
optimizer_max_tokens=optimizer_max_tokens,
468472
optimizer_enable_thinking=optimizer_enable_thinking,
473+
optimizer_use_max_completion_tokens=optimizer_use_max_completion_tokens,
469474
target_base_url=target_base_url,
470475
target_api_key=target_api_key,
471476
target_temperature=target_temperature,
472477
target_timeout_seconds=target_timeout_seconds,
473478
target_max_tokens=target_max_tokens,
474479
target_enable_thinking=target_enable_thinking,
480+
target_use_max_completion_tokens=target_use_max_completion_tokens,
475481
)
476482

477483

‎skillopt/model/qwen_backend.py‎

Lines changed: 60 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
"""OpenAI-compatible Qwen chat backend for optimizer and target paths."""
2+
23
from __future__ import annotations
34

4-
from dataclasses import dataclass
55
import json
66
import os
77
import threading
88
import time
99
import urllib.error
1010
import urllib.request
11+
from dataclasses import dataclass
1112
from typing import Any
1213

1314
from skillopt.model.common import (
@@ -28,6 +29,7 @@ class QwenChatConfig:
2829
temperature: float | None
2930
enable_thinking: bool
3031
deployment: str
32+
use_max_completion_tokens: bool = False
3133

3234

3335
def _parse_bool(value: Any, default: bool = False) -> bool:
@@ -56,6 +58,28 @@ def _role_env(role: str, key: str, default: str) -> str:
5658
return os.environ.get(role_key) or os.environ.get(generic_key) or default
5759

5860

61+
# Sentinels that mean "omit this optional parameter from the request payload".
62+
# Reasoning models (e.g. GPT-5.x, Claude Opus 4.8) reject an explicit
63+
# `temperature`, so allow it to be turned off via an empty string / none / off.
64+
_OMIT_SENTINELS = {"", "none", "off", "null"}
65+
66+
67+
def _resolve_temperature(role: str) -> float | None:
68+
"""Return the temperature, or None to omit it entirely.
69+
70+
Unlike ``_role_env`` an *explicitly set* empty (or ``none``/``off``) value is
71+
honored as "omit" instead of collapsing to the default. Precedence:
72+
role-specific env -> generic env -> 0.7 default.
73+
"""
74+
for key in (f"{role.upper()}_QWEN_CHAT_TEMPERATURE", "QWEN_CHAT_TEMPERATURE"):
75+
if key in os.environ:
76+
raw = os.environ[key].strip()
77+
if raw.lower() in _OMIT_SENTINELS:
78+
return None
79+
return float(raw)
80+
return 0.7
81+
82+
5983
def _initial_config(role: str) -> QwenChatConfig:
6084
role_upper = role.upper()
6185
deployment_env = "OPTIMIZER_DEPLOYMENT" if role == "optimizer" else "TARGET_DEPLOYMENT"
@@ -64,8 +88,9 @@ def _initial_config(role: str) -> QwenChatConfig:
6488
api_key=_role_env(role, "API_KEY", ""),
6589
timeout_seconds=float(_role_env(role, "TIMEOUT_SECONDS", "300") or 300),
6690
max_tokens=_parse_int(_role_env(role, "MAX_TOKENS", "8000"), 8000),
67-
temperature=_parse_optional_float(_role_env(role, "TEMPERATURE", "0.7")),
91+
temperature=_resolve_temperature(role),
6892
enable_thinking=_parse_bool(_role_env(role, "ENABLE_THINKING", "false")),
93+
use_max_completion_tokens=_parse_bool(_role_env(role, "USE_MAX_COMPLETION_TOKENS", "false")),
6994
deployment=(
7095
os.environ.get(f"{role_upper}_QWEN_CHAT_MODEL")
7196
or os.environ.get("QWEN_CHAT_MODEL")
@@ -186,10 +211,14 @@ def _chat_messages_impl(
186211
timeout: float | None = None,
187212
) -> tuple[Any, dict[str, int]]:
188213
config = OPTIMIZER_CONFIG if role == "optimizer" else TARGET_CONFIG
214+
token_limit = min(max_completion_tokens, config.max_tokens)
215+
# Reasoning models on some gateways (GPT-5.x, o-series) require
216+
# `max_completion_tokens` and reject the legacy `max_tokens`.
217+
token_key = "max_completion_tokens" if config.use_max_completion_tokens else "max_tokens"
189218
payload: dict[str, Any] = {
190219
"model": deployment or config.deployment,
191220
"messages": _json_safe(messages),
192-
"max_tokens": min(max_completion_tokens, config.max_tokens),
221+
token_key: token_limit,
193222
}
194223
if config.enable_thinking:
195224
payload["chat_template_kwargs"] = {"enable_thinking": True}
@@ -219,7 +248,7 @@ def _chat_messages_impl(
219248
return text, usage_info
220249
except Exception as e: # noqa: BLE001
221250
last_err = e
222-
time.sleep(min(2 ** attempt, 30))
251+
time.sleep(min(2**attempt, 30))
223252
raise RuntimeError(f"Qwen chat call failed after {retries} retries: {last_err}")
224253

225254

@@ -231,18 +260,21 @@ def configure_qwen_chat(
231260
timeout_seconds: float | str | None = None,
232261
max_tokens: int | str | None = None,
233262
enable_thinking: bool | str | None = None,
263+
use_max_completion_tokens: bool | str | None = None,
234264
optimizer_base_url: str | None = None,
235265
optimizer_api_key: str | None = None,
236266
optimizer_temperature: float | str | None = None,
237267
optimizer_timeout_seconds: float | str | None = None,
238268
optimizer_max_tokens: int | str | None = None,
239269
optimizer_enable_thinking: bool | str | None = None,
270+
optimizer_use_max_completion_tokens: bool | str | None = None,
240271
target_base_url: str | None = None,
241272
target_api_key: str | None = None,
242273
target_temperature: float | str | None = None,
243274
target_timeout_seconds: float | str | None = None,
244275
target_max_tokens: int | str | None = None,
245276
target_enable_thinking: bool | str | None = None,
277+
target_use_max_completion_tokens: bool | str | None = None,
246278
) -> None:
247279
with _config_lock:
248280
if base_url is not None:
@@ -256,29 +288,24 @@ def configure_qwen_chat(
256288
if max_tokens is not None:
257289
os.environ["QWEN_CHAT_MAX_TOKENS"] = str(max_tokens)
258290
if enable_thinking is not None:
259-
os.environ["QWEN_CHAT_ENABLE_THINKING"] = (
260-
"true" if _parse_bool(enable_thinking) else "false"
291+
os.environ["QWEN_CHAT_ENABLE_THINKING"] = "true" if _parse_bool(enable_thinking) else "false"
292+
if use_max_completion_tokens is not None:
293+
os.environ["QWEN_CHAT_USE_MAX_COMPLETION_TOKENS"] = (
294+
"true" if _parse_bool(use_max_completion_tokens) else "false"
261295
)
262296
_update_config(
263297
OPTIMIZER_CONFIG,
264298
"optimizer",
265299
base_url=optimizer_base_url if optimizer_base_url is not None else base_url,
266300
api_key=optimizer_api_key if optimizer_api_key is not None else api_key,
267-
temperature=(
268-
optimizer_temperature
269-
if optimizer_temperature is not None
270-
else temperature
271-
),
272-
timeout_seconds=(
273-
optimizer_timeout_seconds
274-
if optimizer_timeout_seconds is not None
275-
else timeout_seconds
276-
),
301+
temperature=(optimizer_temperature if optimizer_temperature is not None else temperature),
302+
timeout_seconds=(optimizer_timeout_seconds if optimizer_timeout_seconds is not None else timeout_seconds),
277303
max_tokens=optimizer_max_tokens if optimizer_max_tokens is not None else max_tokens,
278-
enable_thinking=(
279-
optimizer_enable_thinking
280-
if optimizer_enable_thinking is not None
281-
else enable_thinking
304+
enable_thinking=(optimizer_enable_thinking if optimizer_enable_thinking is not None else enable_thinking),
305+
use_max_completion_tokens=(
306+
optimizer_use_max_completion_tokens
307+
if optimizer_use_max_completion_tokens is not None
308+
else use_max_completion_tokens
282309
),
283310
)
284311
_update_config(
@@ -287,16 +314,13 @@ def configure_qwen_chat(
287314
base_url=target_base_url if target_base_url is not None else base_url,
288315
api_key=target_api_key if target_api_key is not None else api_key,
289316
temperature=target_temperature if target_temperature is not None else temperature,
290-
timeout_seconds=(
291-
target_timeout_seconds
292-
if target_timeout_seconds is not None
293-
else timeout_seconds
294-
),
317+
timeout_seconds=(target_timeout_seconds if target_timeout_seconds is not None else timeout_seconds),
295318
max_tokens=target_max_tokens if target_max_tokens is not None else max_tokens,
296-
enable_thinking=(
297-
target_enable_thinking
298-
if target_enable_thinking is not None
299-
else enable_thinking
319+
enable_thinking=(target_enable_thinking if target_enable_thinking is not None else enable_thinking),
320+
use_max_completion_tokens=(
321+
target_use_max_completion_tokens
322+
if target_use_max_completion_tokens is not None
323+
else use_max_completion_tokens
300324
),
301325
)
302326

@@ -311,6 +335,7 @@ def _update_config(
311335
timeout_seconds: float | str | None = None,
312336
max_tokens: int | str | None = None,
313337
enable_thinking: bool | str | None = None,
338+
use_max_completion_tokens: bool | str | None = None,
314339
) -> None:
315340
env_prefix = role.upper()
316341
if base_url is not None:
@@ -321,7 +346,7 @@ def _update_config(
321346
os.environ[f"{env_prefix}_QWEN_CHAT_API_KEY"] = config.api_key
322347
if temperature is not None:
323348
raw = str(temperature).strip()
324-
config.temperature = float(raw) if raw else None
349+
config.temperature = None if raw.lower() in _OMIT_SENTINELS else float(raw)
325350
os.environ[f"{env_prefix}_QWEN_CHAT_TEMPERATURE"] = raw
326351
if timeout_seconds is not None:
327352
config.timeout_seconds = float(timeout_seconds)
@@ -331,8 +356,11 @@ def _update_config(
331356
os.environ[f"{env_prefix}_QWEN_CHAT_MAX_TOKENS"] = str(max_tokens)
332357
if enable_thinking is not None:
333358
config.enable_thinking = _parse_bool(enable_thinking)
334-
os.environ[f"{env_prefix}_QWEN_CHAT_ENABLE_THINKING"] = (
335-
"true" if config.enable_thinking else "false"
359+
os.environ[f"{env_prefix}_QWEN_CHAT_ENABLE_THINKING"] = "true" if config.enable_thinking else "false"
360+
if use_max_completion_tokens is not None:
361+
config.use_max_completion_tokens = _parse_bool(use_max_completion_tokens)
362+
os.environ[f"{env_prefix}_QWEN_CHAT_USE_MAX_COMPLETION_TOKENS"] = (
363+
"true" if config.use_max_completion_tokens else "false"
336364
)
337365

338366

‎tests/test_qwen_backend.py‎

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Tests for the OpenAI-compatible Qwen chat backend."""
2+
23
from __future__ import annotations
34

45
import importlib.util
@@ -14,19 +15,17 @@
1415

1516
from skillopt.envs.searchqa.evaluator import extract_answer
1617

17-
1818
_QWEN_CONFIG_ENV_KEYS = (
1919
"BASE_URL",
2020
"API_KEY",
2121
"TEMPERATURE",
2222
"TIMEOUT_SECONDS",
2323
"MAX_TOKENS",
2424
"ENABLE_THINKING",
25+
"USE_MAX_COMPLETION_TOKENS",
2526
)
2627
_ENV_KEYS = ("OPTIMIZER_BACKEND", "TARGET_BACKEND") + tuple(
27-
f"{prefix}QWEN_CHAT_{key}"
28-
for prefix in ("", "OPTIMIZER_", "TARGET_")
29-
for key in _QWEN_CONFIG_ENV_KEYS
28+
f"{prefix}QWEN_CHAT_{key}" for prefix in ("", "OPTIMIZER_", "TARGET_") for key in _QWEN_CONFIG_ENV_KEYS
3029
)
3130

3231

@@ -225,3 +224,75 @@ def test_configure_qwen_chat_runtime_toggle_controls_payload(
225224

226225
assert recorder.calls[0]["payload"]["chat_template_kwargs"] == {"enable_thinking": True}
227226
assert "chat_template_kwargs" not in recorder.calls[1]["payload"]
227+
228+
229+
def test_chat_target_uses_max_tokens_by_default(
230+
monkeypatch: pytest.MonkeyPatch,
231+
isolate_qwen_state: tuple[Any, Any],
232+
) -> None:
233+
model_module, qwen_backend = isolate_qwen_state
234+
_use_qwen_target(model_module, qwen_backend, enable_thinking=False)
235+
recorder = _record_urlopen(monkeypatch, qwen_backend)
236+
237+
model_module.chat_target("system", "user", max_completion_tokens=128, retries=1)
238+
239+
payload = recorder.calls[0]["payload"]
240+
assert payload["max_tokens"] == 128
241+
assert "max_completion_tokens" not in payload
242+
243+
244+
def test_chat_target_uses_max_completion_tokens_when_enabled(
245+
monkeypatch: pytest.MonkeyPatch,
246+
isolate_qwen_state: tuple[Any, Any],
247+
) -> None:
248+
model_module, qwen_backend = isolate_qwen_state
249+
_use_qwen_target(model_module, qwen_backend, enable_thinking=False)
250+
qwen_backend.TARGET_CONFIG.use_max_completion_tokens = True
251+
recorder = _record_urlopen(monkeypatch, qwen_backend)
252+
253+
model_module.chat_target("system", "user", max_completion_tokens=128, retries=1)
254+
255+
payload = recorder.calls[0]["payload"]
256+
assert payload["max_completion_tokens"] == 128
257+
assert "max_tokens" not in payload
258+
259+
260+
def test_configure_qwen_chat_toggles_max_completion_tokens(
261+
monkeypatch: pytest.MonkeyPatch,
262+
isolate_qwen_state: tuple[Any, Any],
263+
) -> None:
264+
model_module, qwen_backend = isolate_qwen_state
265+
_use_qwen_target(model_module, qwen_backend, enable_thinking=False)
266+
recorder = _record_urlopen(monkeypatch, qwen_backend)
267+
268+
model_module.configure_qwen_chat(target_use_max_completion_tokens=True)
269+
model_module.chat_target("system", "user", max_completion_tokens=128, retries=1)
270+
model_module.configure_qwen_chat(target_use_max_completion_tokens=False)
271+
model_module.chat_target("system", "user", max_completion_tokens=128, retries=1)
272+
273+
assert "max_completion_tokens" in recorder.calls[0]["payload"]
274+
assert "max_tokens" in recorder.calls[1]["payload"]
275+
276+
277+
def test_temperature_omitted_when_env_is_blank(
278+
monkeypatch: pytest.MonkeyPatch,
279+
isolate_qwen_state: tuple[Any, Any],
280+
) -> None:
281+
_model_module, qwen_backend = isolate_qwen_state
282+
# Explicit blank means "omit", not "fall back to 0.7".
283+
monkeypatch.setenv("TARGET_QWEN_CHAT_TEMPERATURE", "")
284+
assert qwen_backend._resolve_temperature("target") is None
285+
monkeypatch.setenv("TARGET_QWEN_CHAT_TEMPERATURE", "off")
286+
assert qwen_backend._resolve_temperature("target") is None
287+
288+
289+
def test_temperature_resolves_float_and_default(
290+
monkeypatch: pytest.MonkeyPatch,
291+
isolate_qwen_state: tuple[Any, Any],
292+
) -> None:
293+
_model_module, qwen_backend = isolate_qwen_state
294+
monkeypatch.delenv("QWEN_CHAT_TEMPERATURE", raising=False)
295+
monkeypatch.delenv("TARGET_QWEN_CHAT_TEMPERATURE", raising=False)
296+
assert qwen_backend._resolve_temperature("target") == 0.7
297+
monkeypatch.setenv("TARGET_QWEN_CHAT_TEMPERATURE", "0.2")
298+
assert qwen_backend._resolve_temperature("target") == 0.2

0 commit comments

Comments
 (0)