← Writing

Filtering kwargs with inspect.signature: a function that takes **kwargs looks like it accepts nothing, and a retry lost its model_path

6 min read#Python#LLM#Debugging

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

Table of Contents

When you want to pass optional arguments to a function and aren’t sure it accepts them, a common move is to check the signature first: if "x" in inspect.signature(fn).parameters, and pass it only then. That works for functions that spell out their parameters. For def f(messages, **kwargs), parameters contains just messages and kwargs, and the kwargs entry has kind VAR_KEYWORD. The function accepts any keyword, but by name neither "model_path" nor "max_tokens" is there, so nothing gets passed. Under this check the most permissive function looks the most restrictive.

python
import inspect

def explicit(messages, model_path=None, max_tokens=None): ...
def wrapper(messages, **kwargs): ...

for fn in (explicit, wrapper):
    params = inspect.signature(fn).parameters
    kept = {k: v for k, v in {"model_path": "cloud:x:y", "max_tokens": 512}.items()
            if k in params}
    print(fn.__name__, list(params), kept)

# explicit ['messages', 'model_path', 'max_tokens'] {'model_path': 'cloud:x:y', 'max_tokens': 512}
# wrapper ['messages', 'kwargs'] {}

unittest.mock.MagicMock() has the signature (*args, **kwargs) too, so nothing gets past it either. Neither does a function wrapped by a decorator that doesn’t use functools.wraps.

01The empty-reply retry

Autarkia’s chat flow has a fallback: if the model produces no visible reply in a turn, it asks once more through the non-streaming API. The retry should use the same model and length limit as the first attempt, so it needs to pass model_path and max_tokens along. The code was:

python
chat_fn = agent.llm_module.chat
chat_sig = inspect.signature(chat_fn)
retry_kwargs = {"messages": retry_messages, "temperature": temperature or 1.1}
if "max_tokens" in chat_sig.parameters:
    retry_kwargs["max_tokens"] = max_tokens
if "model_path" in chat_sig.parameters and model_path:
    retry_kwargs["model_path"] = model_path
if "conversation_id" in chat_sig.parameters:
    retry_kwargs["conversation_id"] = user_id
retry_payload = await chat_fn(**retry_kwargs)

agent.llm_module is a HybridLLMModule, which routes between the local model and the various cloud providers based on model_path. Its chat is declared as:

python
async def chat(self, messages: list, **kwargs):
    ...

So none of the three ifs holds, and the retry goes out with only messages and temperature. Without a model_path, HybridLLMModule uses the configured default provider and default model name. On August 4 the main chat was set to Kimi-K2.6 on SiliconFlow; the default provider was also SiliconFlow, but the default model name was deepseek-v4-flash. That’s a model name on DeepSeek’s own API and doesn’t exist on SiliconFlow, so the retry got a 400: code 20012, Model does not exist.

The signature was already **kwargs in the first commit of the git history (June 18). When the filtering code was written I can’t tell: that part of the code only entered git on August 20.

02Why it wasn’t caught sooner

The filtering itself is silent. Dropped arguments leave no log, and the call goes out as usual.

The retry only fires when the first request returns nothing, and first requests mostly fail because something upstream is wrong. So whatever error the retry produces naturally lands among upstream failures. On the night of August 3 into the 4th, SiliconFlow had two overload windows: 503s and dropped streams, with four Model does not exist errors in between, at the non-streaming generate / chat layer, which is the layer the retry uses.

During the day on the 4th, a reproduction showed the retry’s routing log reading provider=siliconflow model=deepseek-v4-flash, which led to this code, and it was fixed. Later the same day, an automated triage went through the previous night’s 38 errors, grouped them into 8 classes, judged all of them transient upstream failures, and changed no code. For the four 400s the conclusion was that “the overloaded gateway returned an inaccurate error”, on the grounds that deepseek-v4-flash is a valid model name used in the project config. It is in the config, under DeepSeek. The triage checked that the name existed, not that it matched the provider, and this was on the very day the bug had been fixed.

Whether all four 400s came from this retry I can’t verify one by one: the raw error file was deleted after triage. The timing, the call layer and the model name all match.

There’s a harder-to-see case, too: if the default model is valid on the default provider, the retry returns a normal reply, just from a different model and with the default length limit. That leaves no error at all. I suspect it happened, but there’s no log to prove it.

The function also had no tests.

03The fix, and what’s left

The fix that day checks for a VAR_KEYWORD parameter first and, if there is one, passes everything without checking names:

python
has_var_keyword = any(
    p.kind == inspect.Parameter.VAR_KEYWORD
    for p in chat_sig.parameters.values()
)
if has_var_keyword:
    if max_tokens is not None:
        retry_kwargs["max_tokens"] = max_tokens
    if model_path:
        retry_kwargs["model_path"] = model_path
    retry_kwargs["conversation_id"] = user_id
else:
    ...  # 原来的按名字检查

That works, but the signature check is still there. All nine chat implementations in the repo now take **kwargs, so the else branch is unreachable; the only one with explicit parameters names its first one prompt rather than messages, so reaching it would raise TypeError anyway. Another spot uses the same check to decide whether to pass system_prompt. Its target has explicit parameters today, so it’s fine, but wrap it in a decorator or replace it with a MagicMock in a test and system_prompt silently stops being passed.

I plan to remove the signature probing. llm_module.chat is an interface inside my own repo, so it can have one agreed signature (model_path, max_tokens and conversation_id as named optional parameters), and callers pass them as keywords directly. Any implementation that doesn’t accept them should fail with TypeError on the first call, which is far easier to track down than a quietly missing argument. The retry also gets a test: a real HybridLLMModule with the cloud client stubbed, asserting the retry routes to the same model as the first attempt.

If you really do have to deal with functions you don’t control, “does it accept x” needs to be at least “x is in parameters, or parameters has a VAR_KEYWORD entry”.

Related

Generator 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#AutarkiaLLM 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#AutarkiaOne pop() in a subclass payload builder: max_tokens deleted before sending, invisible to callers, logs and tests2026-10-21The 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.#Python#LLM#Observability