diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 44b11dfaa6d0..4bd1710ef252 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -4726,7 +4726,9 @@ def _build_call_kwargs( if max_tokens is not None: # Codex adapter handles max_tokens internally; OpenRouter/Nous use max_tokens. - # Direct OpenAI api.openai.com with newer models needs max_completion_tokens. + # Direct OpenAI api.openai.com and GitHub Copilot api.githubcopilot.com with + # newer GPT-4o/o-series/GPT-5 models reject max_tokens and require + # max_completion_tokens instead. # ZAI vision models (glm-4v-flash, glm-4v-plus, etc.) reject max_tokens with # error code 1210 ("API 调用参数有误") on multimodal requests — skip it. _model_lower = (model or "").lower() @@ -4734,14 +4736,22 @@ def _build_call_kwargs( provider == "zai" and ("4v" in _model_lower or "5v" in _model_lower or "-v" in _model_lower) ) + # Endpoints that require max_completion_tokens for newer OpenAI models, + # keyed on the resolved request hostname rather than the provider label. + # GitHub Copilot normalises to provider="copilot" (not "custom"), so a + # provider=="custom" gate alone misses it — mirror auxiliary_max_tokens_param, + # which already lists both hosts. See issue #34530. + _resolved_base = base_url + if not _resolved_base and provider == "custom": + _resolved_base = _current_custom_base_url() + _wants_max_completion = base_url_hostname(_resolved_base) in { + "api.openai.com", + "api.githubcopilot.com", + } if _skip_max_tokens: pass # ZAI vision models do not accept max_tokens - elif provider == "custom": - custom_base = base_url or _current_custom_base_url() - if base_url_hostname(custom_base) == "api.openai.com": - kwargs["max_completion_tokens"] = max_tokens - else: - kwargs["max_tokens"] = max_tokens + elif _wants_max_completion: + kwargs["max_completion_tokens"] = max_tokens else: kwargs["max_tokens"] = max_tokens diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 66e52b6c10b9..d6e684c48fd2 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -88,6 +88,87 @@ def test_uses_max_completion_tokens_for_github_copilot_custom_base_path(self): assert auxiliary_max_tokens_param(2048) == {"max_completion_tokens": 2048} +class TestBuildCallKwargsMaxTokensParam: + """_build_call_kwargs must emit max_completion_tokens for endpoints that + reject max_tokens on newer OpenAI models, keyed on the resolved request + hostname rather than the provider label. + + Regression test for issue #34530: auxiliary context compression configured + with provider=github-copilot (which normalises to provider="copilot", not + "custom") and a GPT-5 model sent max_tokens and failed with HTTP 400 + "Unsupported parameter: 'max_tokens' ... Use 'max_completion_tokens'". + """ + + def test_github_copilot_provider_uses_max_completion_tokens(self): + # github-copilot normalises to provider="copilot"; the bug was that the + # max_completion_tokens path was gated on provider=="custom". + kwargs = _build_call_kwargs( + provider="copilot", + model="gpt-5.4", + messages=[{"role": "user", "content": "hi"}], + max_tokens=1024, + base_url="https://api.githubcopilot.com", + ) + assert kwargs.get("max_completion_tokens") == 1024 + assert "max_tokens" not in kwargs + + def test_github_copilot_base_with_path_uses_max_completion_tokens(self): + kwargs = _build_call_kwargs( + provider="copilot", + model="gpt-5.5", + messages=[{"role": "user", "content": "hi"}], + max_tokens=512, + base_url="https://api.githubcopilot.com/chat/completions", + ) + assert kwargs.get("max_completion_tokens") == 512 + assert "max_tokens" not in kwargs + + def test_openai_base_still_uses_max_completion_tokens(self): + kwargs = _build_call_kwargs( + provider="openai", + model="gpt-5.4", + messages=[{"role": "user", "content": "hi"}], + max_tokens=2048, + base_url="https://api.openai.com/v1", + ) + assert kwargs.get("max_completion_tokens") == 2048 + assert "max_tokens" not in kwargs + + def test_custom_openai_base_still_uses_max_completion_tokens(self): + # Preserve the pre-existing provider=="custom" + api.openai.com behaviour. + kwargs = _build_call_kwargs( + provider="custom", + model="gpt-5.4", + messages=[{"role": "user", "content": "hi"}], + max_tokens=2048, + base_url="https://api.openai.com/v1", + ) + assert kwargs.get("max_completion_tokens") == 2048 + assert "max_tokens" not in kwargs + + def test_openrouter_still_uses_max_tokens(self): + kwargs = _build_call_kwargs( + provider="openrouter", + model="openai/gpt-5.4", + messages=[{"role": "user", "content": "hi"}], + max_tokens=4096, + base_url="https://openrouter.ai/api/v1", + ) + assert kwargs.get("max_tokens") == 4096 + assert "max_completion_tokens" not in kwargs + + def test_no_base_url_non_custom_provider_uses_max_tokens(self): + # Defensive: an unknown host (empty) must keep max_tokens, not flip. + kwargs = _build_call_kwargs( + provider="deepseek", + model="deepseek-chat", + messages=[{"role": "user", "content": "hi"}], + max_tokens=1000, + ) + assert kwargs.get("max_tokens") == 1000 + assert "max_completion_tokens" not in kwargs + + class TestNormalizeAuxProvider: def test_maps_github_copilot_aliases(self): assert _normalize_aux_provider("github") == "copilot" diff --git a/tests/agent/test_unsupported_temperature_retry.py b/tests/agent/test_unsupported_temperature_retry.py index 82d8d3208d29..3bffb1b4062a 100644 --- a/tests/agent/test_unsupported_temperature_retry.py +++ b/tests/agent/test_unsupported_temperature_retry.py @@ -112,8 +112,12 @@ def test_retries_once_without_temperature(self, error_message): retry_kwargs = client.chat.completions.create.call_args_list[1].kwargs assert first_kwargs["temperature"] == 0.3 assert "temperature" not in retry_kwargs - # other kwargs preserved - assert retry_kwargs["max_tokens"] == 500 + # other kwargs preserved. api.openai.com normalises max_tokens to + # max_completion_tokens (issue #34530), so accept either. + assert ( + retry_kwargs.get("max_tokens") == 500 + or retry_kwargs.get("max_completion_tokens") == 500 + ) def test_non_temperature_400_does_not_retry_as_temperature(self): """Unrelated 400s (e.g. bad tool role) must not silently drop temp.""" @@ -207,7 +211,12 @@ async def test_async_retries_once_without_temperature(self): retry_kwargs = client.chat.completions.create.call_args_list[1].kwargs assert first_kwargs["temperature"] == 0.3 assert "temperature" not in retry_kwargs - assert retry_kwargs["max_tokens"] == 500 + # api.openai.com normalises max_tokens to max_completion_tokens + # (issue #34530), so accept either form on the retry call. + assert ( + retry_kwargs.get("max_tokens") == 500 + or retry_kwargs.get("max_completion_tokens") == 500 + ) @pytest.mark.asyncio async def test_async_non_temperature_400_does_not_retry(self):