One pop() in a subclass payload builder: max_tokens deleted before sending, invisible to callers, logs and tests
The parent class builds the request body, the subclass pops a field before returning it, and whatever the caller passes stops mattering without any error. Autarkia’s MiniMax client has deleted every caller’s max_tokens since the first commit, leaving a reasoning model with no output ceiling, and it was mentioned three times in my notes without being changed.
A common client layout: the base class turns parameters into a request body, and each provider subclass overrides _build_payload, calls super(), then adds or removes fields for its own API. The catch is that a field the subclass removes is invisible to callers. The caller passes max_tokens=400, the unit test asserts 400 was passed, the log records the call arguments, and no layer ever looks at the dict that actually goes out.
01The line
Autarkia’s MiniMax client looks like this:
class MiniMaxClient(OpenAIClient):
def _build_payload(self, messages: list, stream: bool, **kwargs):
thinking = kwargs.pop("thinking_enabled", None)
payload = super()._build_payload(messages, stream, **kwargs)
if thinking is False:
payload["thinking"] = {"type": "disabled"}
payload["reasoning_split"] = True
payload.pop("max_tokens", None)
...
return payloadThe parent does the right thing:
def _build_payload(self, messages: list, stream: bool, **kwargs):
model = kwargs.pop("model", self.default_model)
max_tokens = kwargs.pop("max_tokens", self.default_max_tokens)
...
payload = build_payload(messages=..., model=model, max_tokens=max_tokens, ...)Read the parent alone and max_tokens is in the body; read the caller alone and the configured value is read and passed in. Between them sits one pop in the subclass, and the value is dropped before it leaves the process. The line is already present in the first commit of the git history; when and why it was added can no longer be traced.
Later every business model moved to MiniMax, so that one line applied to every call: the 400 configured for proactive message generation, the 4096 for memory extraction, the various nightly budgets — none were sent. And since these are reasoning models, they answer fine with no ceiling; they just think as long as they like.
02It showed up in my notes three times
It’s not that I never saw it. My notes on September 11 say the MiniMax client “still pops max_tokens (the API doesn’t accept the field), so the max_tokens nightly passes has no effect; left as is”. On October 14, investigating the cost of proactive messages, one recorded cause was that output is billed at 8.4 yuan per million tokens “with no ceiling, because the MiniMax client pops max_tokens”; output tokens were seventy percent of that bill. On October 22, wiring up a new model, I noted again that it “still pops max_tokens; client behaviour unchanged”.
All three times I took the parenthetical as given: the API doesn’t accept the field, so it has to go. An evaluation experiment on October 26 disproved it. That experiment didn’t use this client; it built its own requests and sent them to the same MiniMax endpoint with max_tokens. The budget turned out too tight: a reasoning model’s thinking tokens count toward max_tokens, and in one arm 169 of 237 calls came back with an empty answer. The API not only accepts the field, it enforces it strictly.
03Why it never surfaced
Every layer saw something correct.
- Callers: the config was read and the argument passed.
- Logs: the call stats are taken from the final payload, but the default line only prints provider, model, message count, character count and stream mode;
max_tokensappears only with full prompt logging turned on. - Tests: the memory-extraction unit test mocks the scheduler and asserts
call["max_tokens"] >= 4096, with a comment saying thinking tokens count toward max_tokens so the budget must be at least 4096. I knew the budget mattered when I wrote it, but the assertion sits before the client, so it passes whether the client drops the field or not. - Behaviour: a reasoning model with no ceiling still gives normal answers. No error, no truncation, just a high output share on the bill.
The subclass override is the structural reason it could hide. The author of the base class, the author of the callers, and whoever wrote the tests all had reason not to look inside one provider’s _build_payload for deletions, and all of them were me.
04The fix
The line hasn’t been changed in the main project yet. The fix I’ve written down has three parts. First, delete the line; if some model really only accepts max_completion_tokens, rename the field instead of discarding it:
if "max_tokens" in payload:
payload["max_completion_tokens"] = payload.pop("max_tokens")Second, put the assertion on the final payload: call _build_payload(..., max_tokens=123) on every client subclass and assert the returned dict carries the value. The test doesn’t depend on any provider, so new subclasses are covered automatically.
@pytest.mark.parametrize("cls", all_client_subclasses())
def test_payload_keeps_output_budget(cls):
payload = cls(api_key="x")._build_payload(
[{"role": "user", "content": "hi"}], stream=False, max_tokens=123
)
assert 123 in (payload.get("max_tokens"), payload.get("max_completion_tokens"))Third, include the final payload’s max_tokens in the default log line. Had it been printing None all along, this would have been visible on day one.