17 Commits

  • fix: assistant messages with only tool calls are sent with an empty string instead of null (#34265)
    When an assistant message has tool calls but no text, the OpenAI Chat Completions format uses `content: null`, since content is optional next to tool calls and OpenAI's own API returns null there. Open WebUI sent an empty string in two places: when replaying earlier tool calls from the chat history, and when converting requests from clients that use its Anthropic-compatible endpoint. For Claude models, LiteLLM 1.81.14 through 1.108.x replaces that empty string with "[System: Empty message content sanitised to satisfy protocol]", and the model then repeats that line in its replies (LiteLLM 1.109 and later leave the empty text out instead). These messages now go out with null content, which OpenAI and every provider tested on OpenRouter (Gemini, Claude, DeepSeek, Qwen, Mistral, Llama, Kimi and Grok) accept.
    
    Fixes #34264
  • fix: stop the background memory review when memory is switched off (#30309)
    With memories disabled instance-wide, or for a user barred from the feature,
    the background review still ran every interval turn: it spent a task-model
    call drafting memory operations and only then failed at the write, because
    the router's permission check rejected it. The review now checks the
    'memories.enable' switch and re-checks the 'features.memories' permission
    the same way the context-injection path already does, so a model whose memory
    capability is on no longer triggers memory work that can never land.
    
    The permission lookup costs a groups query, so it runs last, after the free
    config and interval gates; those stay on every turn's hot path.
  • perf: build debug log messages lazily so disabled debug logs cost nothing (#27834)
    GLOBAL_LOG_LEVEL defaults to INFO, so every log.debug(...) in the backend is discarded, but the message is built first: 187 call sites interpolate their payload into an f-string before the logging call runs, so the work happens on every request and the result is thrown away. The worst one sits in process_chat_payload and stringifies the whole request body, full conversation history included, once per chat completion.
    
    That one line with DEBUG disabled, CPython 3.12:
    
    | conversation | payload | before   | after   |
    | ------------ | ------- | -------- | ------- |
    | 4 messages   | 1.2 kB  | 3.4 us   | 0.07 us |
    | 20 messages  | 17 kB   | 24.8 us  | 0.07 us |
    | 60 messages  | 123 kB  | 216.6 us | 0.07 us |
    
    The lazy form log.debug('form_data: %s', form_data) hands the payload to record.getMessage(), which the InterceptHandler only reaches once a record has passed the level check. With DEBUG enabled the emitted lines are byte-identical, f'{x=}' sites included: those map to %r. MistralLoader._debug_log callers get the same treatment, since that wrapper already forwards *args.