Filtering kwargs with inspect.signature: a function that takes **kwargs looks like it accepts nothing, and a retry lost its model_path
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.
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.
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:
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:
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:
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”.