fix(bedrock_mantle): send max_completion_tokens for gpt-5.6 and gpt-6 chat completions (#43982)

GPT reasoning-series models (gpt-5.x, gpt-6) on Bedrock Mantle chat completions now send max_completion_tokens, carrying a deployment or client max_tokens over with its value. Every other Mantle chat model keeps sending max_tokens.
This commit is contained in:
devin-ai-integration[bot] 2026-10-11 18:28:48 +00:00 • committed by GitHub
parent 464a4dba9d
commit c0ca4a51f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1273 additions and 1 deletions

View file

@ -128,6 +128,30 @@ class BedrockMantleChatConfig(BedrockMantleAuthMixin, OpenAILikeChatConfig):
)
return [*base_params, *extra_params]
def map_openai_params(
self,
non_default_params: dict[str, object], # mutable-ok: OpenAILikeChatConfig.map_openai_params signature
optional_params: dict[str, object], # mutable-ok: OpenAILikeChatConfig.map_openai_params signature
model: str,
drop_params: bool,
replace_max_completion_tokens_with_max_tokens: bool = True,
) -> dict[str, object]: # mutable-ok: OpenAILikeChatConfig.map_openai_params signature
gpt_reasoning_model: Final = is_gpt_reasoning_series_name(model)
mapped_params: Final[dict[str, object]] = super().map_openai_params( # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # OpenAILikeChatConfig types its params and result as bare dicts # mutable-ok: parent returns a dict
non_default_params,
optional_params,
model,
drop_params,
replace_max_completion_tokens_with_max_tokens and not gpt_reasoning_model,
)
if not gpt_reasoning_model or "max_tokens" not in mapped_params:
return mapped_params
return {
param: value
for param, value in (("max_completion_tokens", mapped_params["max_tokens"]), *mapped_params.items())
if param != "max_tokens"
}
def _supports_reasoning(self, model: str) -> bool:
try:
return litellm.supports_reasoning(model=model, custom_llm_provider=self.custom_llm_provider)

View file

@ -0,0 +1,179 @@
"""Live e2e for output-token limits on a Bedrock Mantle GPT-5.6 chat deployment.
A deployment registered with model_info.mode "chat" and an explicit Mantle
OpenAI-compatible api_base is served through Mantle chat completions. Mantle
rejects max_tokens for the GPT-5.x models there and only accepts
max_completion_tokens, so a caller that sets an output limit, or a deployment
that stores one, must still get a completion back instead of the upstream 400
"Unsupported parameter: 'max_tokens' is not supported with this model."
"""
from __future__ import annotations
from collections.abc import Iterator
from typing import Final
import pytest
from e2e_config import unique_marker
from e2e_metadata import Capability, Domain, Mode, Provider, Route, Subject, meta
from lifecycle import ResourceManager
from llm_translation.sdk_clients import NO_PROXY_CACHE, SdkClients
from models import LiteLLMParamsBody, ModelInfoBody, ModelNewBody
from openai.types.chat import (
ChatCompletion,
ChatCompletionChunk,
ChatCompletionToolParam,
ChatCompletionUserMessageParam,
)
from openai.types.chat.chat_completion_chunk import Choice, ChoiceDeltaToolCall
from passthrough_client import PassthroughClient
from pydantic import BaseModel, ConfigDict
pytestmark: Final = pytest.mark.e2e
MANTLE_BACKEND: Final = "bedrock_mantle/openai.gpt-5.6-terra"
MANTLE_CHAT_API_BASE: Final = "https://bedrock-mantle.us-east-1.api.aws/openai/v1"
OUTPUT_LIMIT: Final = 1000
PROMPT: Final = "reply with one word"
WEATHER_PROMPT: Final = "What is the weather in Paris?"
WEATHER_TOOL: Final[ChatCompletionToolParam] = {
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the weather for a city",
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
},
}
def _register_mantle_chat_model(
client: PassthroughClient, resources: ResourceManager, max_tokens: int | None = None
) -> str:
model: Final = f"e2e-mantle-chat-output-limit-{unique_marker()}"
model_id: Final = client.proxy.register_model(
ModelNewBody(
model_name=model,
litellm_params=LiteLLMParamsBody(
model=MANTLE_BACKEND,
custom_llm_provider="bedrock_mantle",
api_key="os.environ/AWS_BEARER_TOKEN_BEDROCK",
api_base=MANTLE_CHAT_API_BASE,
reasoning_effort="none",
max_tokens=max_tokens,
),
model_info=ModelInfoBody(mode="chat", supported_endpoints=["/v1/chat/completions", "/v1/responses"]),
)
)
resources.defer(lambda: client.proxy.delete_model(model_id))
return model
def _user_message(text: str) -> ChatCompletionUserMessageParam:
return {"role": "user", "content": text}
def _assert_completion_within_limit(completion: ChatCompletion) -> None:
assert completion.choices[0].message.content, f"empty completion: {completion.model_dump_json()[:400]}"
assert completion.usage is not None, f"completion carries no usage: {completion.model_dump_json()[:400]}"
assert completion.usage.completion_tokens <= OUTPUT_LIMIT, f"limit not applied: {completion.usage}"
class _WeatherArgs(BaseModel):
model_config = ConfigDict(frozen=True)
city: str
def _streamed_choices(chunks: tuple[ChatCompletionChunk, ...]) -> Iterator[Choice]:
for chunk in chunks:
yield from chunk.choices
def _streamed_tool_calls(chunks: tuple[ChatCompletionChunk, ...]) -> Iterator[ChoiceDeltaToolCall]:
for choice in _streamed_choices(chunks):
yield from choice.delta.tool_calls or ()
def _streamed_tool_call(chunks: tuple[ChatCompletionChunk, ...]) -> tuple[str, str]:
calls: Final = tuple(call.function for call in _streamed_tool_calls(chunks) if call.function is not None)
name: Final = "".join(call.name or "" for call in calls)
arguments: Final = "".join(call.arguments or "" for call in calls)
return name, arguments
class TestBedrockMantleChatOutputLimit:
@meta(
Subject(
domain=Domain.LLM_TRANSLATION,
route=Route.CHAT_COMPLETIONS,
providers=(Provider.BEDROCK_MANTLE,),
models=(MANTLE_BACKEND,),
mode=Mode.NONSTREAM,
)
)
def test_client_max_tokens_returns_completion(
self, client: PassthroughClient, resources: ResourceManager, sdk: SdkClients
) -> None:
model: Final = _register_mantle_chat_model(client, resources)
completion: Final = sdk.openai(resources.key()).chat.completions.create(
model=model,
messages=[_user_message(PROMPT)],
max_tokens=OUTPUT_LIMIT,
extra_body=NO_PROXY_CACHE,
)
_assert_completion_within_limit(completion)
@meta(
Subject(
domain=Domain.LLM_TRANSLATION,
route=Route.CHAT_COMPLETIONS,
providers=(Provider.BEDROCK_MANTLE,),
models=(MANTLE_BACKEND,),
mode=Mode.NONSTREAM,
)
)
def test_client_max_completion_tokens_returns_completion(
self, client: PassthroughClient, resources: ResourceManager, sdk: SdkClients
) -> None:
model: Final = _register_mantle_chat_model(client, resources)
completion: Final = sdk.openai(resources.key()).chat.completions.create(
model=model,
messages=[_user_message(PROMPT)],
max_completion_tokens=OUTPUT_LIMIT,
extra_body=NO_PROXY_CACHE,
)
_assert_completion_within_limit(completion)
@meta(
Subject(
domain=Domain.LLM_TRANSLATION,
route=Route.CHAT_COMPLETIONS,
providers=(Provider.BEDROCK_MANTLE,),
models=(MANTLE_BACKEND,),
capabilities=(Capability.FUNCTION_CALLING, Capability.TOOL_CHOICE),
mode=Mode.STREAM,
)
)
def test_deployment_max_tokens_streams_tool_call_completion(
self, client: PassthroughClient, resources: ResourceManager, sdk: SdkClients
) -> None:
model: Final = _register_mantle_chat_model(client, resources, max_tokens=OUTPUT_LIMIT)
chunks: Final = tuple(
sdk.openai(resources.key()).chat.completions.create(
model=model,
messages=[_user_message(WEATHER_PROMPT)],
stream=True,
stream_options={"include_usage": True},
tools=[WEATHER_TOOL],
tool_choice="required",
extra_body=NO_PROXY_CACHE,
)
)
finish_reasons: Final = tuple(choice.finish_reason for choice in _streamed_choices(chunks))
assert "tool_calls" in finish_reasons, f"stream did not finish on a tool call: {finish_reasons}"
assert any(chunk.usage is not None for chunk in chunks), "stream carried no usage chunk"
name, arguments = _streamed_tool_call(chunks)
assert name == "get_weather", f"streamed tool call named {name!r}: {chunks[:5]}"
assert _WeatherArgs.model_validate_json(arguments).city.strip(), (
f"streamed tool call arguments missing city: {arguments!r}"
)

View file

@ -1391,6 +1391,10 @@ class LiteLLMParamsBody(BaseModel):
input_cost_per_token_flex: float | None = None
output_cost_per_token_flex: float | None = None
cache_read_input_token_cost_flex: float | None = None
max_tokens: int | None = None
max_completion_tokens: int | None = None
custom_llm_provider: str | None = None
reasoning_effort: str | None = None
extra_headers: dict[str, str] | None = Field(default=None, repr=False)
use_in_pass_through: bool | None = None
complexity_router_config: dict[str, object] | None = None
@ -1409,7 +1413,7 @@ class LiteLLMParamsBody(BaseModel):
order: int | None = None
ModelMode = Literal["batch", "realtime", "image_generation"]
ModelMode = Literal["chat", "batch", "realtime", "image_generation"]
class ModelInfoBody(BaseModel):
@ -1419,6 +1423,7 @@ class ModelInfoBody(BaseModel):
# constraint when a prior run's teardown had not removed the row.
id: str | None = None
mode: ModelMode | None = None
supported_endpoints: list[str] | None = None
access_groups: list[str] | None = None
team_id: str | None = None
allowed_fails: int | None = None

File diff suppressed because it is too large Load diff

View file

@ -246,6 +246,43 @@ class TestBedrockMantleConfig:
)
assert optional_params["verbosity"] == "low"
@pytest.mark.parametrize(
"model", ["openai.gpt-5.6-terra", "us-gov-west-1/openai.gpt-5.6-terra", "openai.gpt-6-sol"]
)
@pytest.mark.parametrize("limit_param", ["max_tokens", "max_completion_tokens"])
def test_gpt_reasoning_series_output_limit_is_sent_as_max_completion_tokens(
self, model: str, limit_param: str
) -> None:
optional_params = litellm.get_optional_params(
model=model,
custom_llm_provider="bedrock_mantle",
drop_params=False,
**{limit_param: 1000},
)
assert optional_params["max_completion_tokens"] == 1000
assert "max_tokens" not in optional_params
def test_gpt_reasoning_series_explicit_max_completion_tokens_wins_over_max_tokens(self):
optional_params = litellm.get_optional_params(
model="openai.gpt-5.6-terra",
custom_llm_provider="bedrock_mantle",
drop_params=False,
max_tokens=1000,
max_completion_tokens=50,
)
assert optional_params["max_completion_tokens"] == 50
assert "max_tokens" not in optional_params
def test_non_gpt_reasoning_models_keep_the_openai_compatible_max_tokens(self):
optional_params = litellm.get_optional_params(
model="openai.gpt-oss-120b",
custom_llm_provider="bedrock_mantle",
drop_params=False,
max_completion_tokens=1000,
)
assert optional_params["max_tokens"] == 1000
assert "max_completion_tokens" not in optional_params
class TestBedrockMantleChatAuth:
"""Chat Completions must use the same Bearer-or-SigV4 auth as the Responses