← Writing

One pop() in a subclass payload builder: max_tokens deleted before sending, invisible to callers, logs and tests

5 min read#Python#LLM#Observability

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.

Table of Contents

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:

python
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 payload

The parent does the right thing:

python
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_tokens appears 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:

python
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.

python
@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.

Related

LLM Request Observability: Logs Count Messages, and the Native Tools Schema Shows Up Nowhere2026-11-07With native function calling, tool definitions go in the tools parameter, not in messages. A log that measures message length can’t see them, and streaming responses omit usage by default. Autarkia sent 82 tools and 47,898 characters of schema every turn while the prompt log showed about 2,200 characters; the API counted 21k–23k tokens.#LLM#Python#Observability#AutarkiaFiltering kwargs with inspect.signature: a function that takes **kwargs looks like it accepts nothing, and a retry lost its model_path2026-11-08inspect.signature(f).parameters lists only declared parameters; **kwargs shows up as a single entry named kwargs. Checking by name whether a callee accepts an argument lets nothing through to the most permissive function. Autarkia’s empty-reply retry lost its model_path this way and fell back to the default model; the resulting 400s landed among upstream 503s, and that day’s error triage filed them as gateway noise.#Python#LLM#DebuggingGenerator Exceptions Surface on the First next(): try Around create_chat_completion(stream=True) Never Sees the Context Window Error2026-11-08Calling a generator function only builds a generator; the body, including any raise in it, runs on the first next(). llama-cpp-python checks the context window inside such a body, so the try around a streaming call, and the shrink-and-retry under it, never fire with the real library. The test double raised at call time, so the tests passed with 100% coverage.#Python#LLM#Error Handling#Testing#Autarkia