Skip to content

feat(google): native tool calling through Open WebUI's tool loop (1.18.0) - #193

Merged
owndev merged 17 commits into
mainfrom
feature/gemini-native-tool-calling
Oct 10, 2026
Merged

owndev merged 17 commits into
mainfrom
feature/gemini-native-tool-calling

Conversation

@owndev

@owndev owndev commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

Problem

Native tool calling in the Gemini pipeline was broken on Open WebUI 0.10+ (#169, #155):

  • The pipe passed Open WebUI's __tools__ callables to google-genai's automatic function calling (AFC), which runs the tools inside the pipe:
    • direct tools have no callable (KeyError: 'callable');
    • MCP callables have no __signature__ (AttributeError);
    • OpenAPI and MCP callables are all named tool_function (Duplicate function declaration);
    • Open WebUI loads plugins with from __future__ import annotations, so workspace tools carry string annotations that AFC cannot coerce.
  • The built-in tools that Open WebUI adds to every browser chat were never declared.
  • Tool approval, citations, embeds and the iteration limit of Open WebUI's tool loop were bypassed.
  • API clients sending tools got no tool_calls back. Tool messages in the history were turned into user text.

Change (Google Gemini 1.17.0 → 1.18.0)

  • No AFC. Tools are declared from body["tools"], the OpenAI tool specs Open WebUI builds for every tool type, as FunctionDeclaration(parameters_json_schema=…) with a sanitised schema. Names Gemini does not accept get a stable hash suffix and are mapped back. AutomaticFunctionCallingConfig(disable=True) everywhere.
  • Open WebUI runs the tools. Gemini's function calls are returned as OpenAI tool_calls. On the browser path, Open WebUI's tool loop executes them and calls the pipe again with assistant.tool_calls + role:"tool" messages. That covers built-in, workspace Python, MCP, OpenAPI, terminal and direct tools, plus tool approval. Browser stream=false answers a tool round as a stream so the loop can run.
  • Thought signatures travel as reasoning_details and are restored per call id in later rounds and turns. When a client does not echo them, the documented placeholder is used, only for the current turn.
  • History conversion. Tool calls and tool results become function_call / function_response parts (responses in call order, {"error": …} for Open WebUI's error results). Unmatched calls and results are dropped with a WARNING. Open WebUI's tool-image message is handled.
  • API clients get tool_calls streamed and non-streamed with finish_reason: "tool_calls", including through the openai SDK's stream reader. tool_choice is mapped (none, required, a named function); parallel_tool_calls is ignored.
  • Grounding with tools. Gemini 3 on the Gemini API sends Google Search + URL context + functions with include_server_side_tool_invocations. Gemini 2.x and Vertex send grounding only, with no declarations for that request. Image models, background tasks and Legacy mode send no tools.
  • Errors. MALFORMED_FUNCTION_CALL / UNEXPECTED_TOOL_CALL give a clear message instead of an empty answer.
  • Requirements. google-genai>=1.68.0, google-genai<3, the first version with server-side tool invocations. The docs explain what to do on offline Open WebUI 0.9.0–0.11.3, where pip cannot upgrade the bundled 1.66.0.
  • No valve was added, renamed or removed. No-tool chats, image models, Veo, tasks, retries and statuses are unchanged.
  • Docs: the docstring, a new "Native tool calling" section in docs/google-gemini-integration.md, and README.

E2E

  • New groups:

    • gemini.tools: the browser path through Open WebUI's real tool loop;
    • gemini.toolsapi: API clients.

    With them the gemini suite grows from 81 to 129 checks. On 1.17.0, 42 of the 48 tool checks fail; the commit body of 04f8a86 lists them per root cause.

  • New mocks: the MOCKTOOLS: directive in the Gemini mock, which emulates Gemini's 400 validations for pairing, signatures and names; mock_tools.py (an OpenAPI server and an MCP server); a workspace tool.

  • Harness fix: the browser session now uses the Socket.IO namespace sid and sends a heartbeat. Before, direct tools and __event_call__ failed with "Client session disconnected".

  • Real-API smoke runner (manual, needs a key): GOOGLE_API_KEY=… tests/e2e/smoke/gemini-tools.sh. It runs one scenario per spec risk R1–R15 through the real tool loop and reports PASS, FAIL or SKIP per risk. Retries are bounded, there is a hard request cap, and the key never appears in any output.

Also fixed here: image edit loses the previous image (#194)

Since 1.15.2, a generated image in a browser chat is attached to the answer only as an Open WebUI file. Open WebUI 0.11.x never passes files of earlier assistant messages to a pipe. An edit request ("change the name on the certificate") therefore reached Gemini as text only, and Gemini drew an unrelated image. 1.17.0 and the earlier 1.18.0 state of this branch behaved the same way.

  • Saved chats: for a saved chat of the requesting user (or any chat for an admin), the image path loads the message chain from Open WebUI's database, as load_messages_from_db does. It sends the images of earlier user and assistant messages as history images: file ids, /api/v1/files/<id>/content, data: URLs and markdown links.
  • Fallback: API clients, temporary chats, background tasks and database errors keep using the request's messages. Earlier model turns are not replayed, so no thought signatures are involved.
  • Image limit and dedup: over IMAGE_HISTORY_MAX_REFERENCES the oldest history images are dropped first and current images are never dropped. Duplicates keep their newest position, and an earlier image that is attached again to the current message is not sent twice. Guided regeneration keeps the images of the regenerated message.
  • Hardening (side finding): _fetch_file_as_base64 used to read any file id in a message. It now reads a file only for its owner or an admin, on every path including Veo image-to-video, and logs no foreign file id. Image decoding runs in a worker thread.
  • E2E: new group gemini.imgedit (19 checks: two-turn edit on Nano Banana 2 and gemini-nano-banana-2.1, uploads, guided regeneration variants, limit, dedup, temporary chat, task, admin, foreign file ids on the browser and API path, a database error, Veo). 15 of the 19 fail on the pre-fix file. A first round of mutation testing left 10 mutants alive; the tests were extended, and all 18 mutants are killed now.

Verification

Run Result
After merging main 4b80e24 (#195) and #191: all suites, v0.11.4-slim normal and --strict-known, v0.11.3-slim --strict-known 530 PASS, 0 FAIL, 1 KNOWN (infomaniak-name-prefix, no fix yet), 531 checks, each
All suites, v0.11.4-slim, --strict-known (merged with main 3ff6cf9) 363 PASS, 0 FAIL, 1 KNOWN (infomaniak-name-prefix, no fix yet), 364 checks
gemini suite, v0.11.3-slim and v0.11.4-slim, --strict-known 149 PASS, 0 FAIL, 0 KNOWN each (on 0.11.3, pip upgrades the bundled google-genai 1.66.0 on save)
Meta-test (E2E_MOCK_FAULT=500), after the merges 57 PASS, 370 FAIL, 0 KNOWN
Offline unit/integration script, google-genai 1.68.0 and 2.29.0 97/97 each
Mutation testing first round: 23 surviving mutants, the tests extended; 27/27 mutants killed afterwards
Smoke runner --dry-run-mock 15 PASS, 0 FAIL, 2 SKIP; bad key → exit 2 "API key not valid"; budget cap holds

The design is based on a probe of Open WebUI 0.11.4's tool loop: continuation shapes, reasoning_details round trip, the required index, merged rounds, usage summing. Three reviews followed: spec and API, Open WebUI regressions, and mutation testing. All 19 findings were fixed.

Before release: run the real-API smoke test

Everything above ran against mocks. These are open risks R1–R15 that only Google's servers can answer:

  • acceptance of the sanitised schemas;
  • the skip_thought_signature_validator placeholder;
  • tool combination with replayed server-side parts;
  • history without declarations.

Please run GOOGLE_API_KEY=… tests/e2e/smoke/gemini-tools.sh; about 39 small requests, or add --retries 0.

Closes #169
Closes #155
Closes #194

Supersedes #168 (thanks @ctroche99) and #159 (thanks @olivier-lacroix). Both addressed parts of this. This PR moves tool execution to Open WebUI's own tool loop instead of running tools inside the pipe.

🤖 Generated with Claude Code

owndev and others added 17 commits October 9, 2026 12:48
Gemini 1.17.0 handed Open WebUI's tool callables to google-genai's automatic
function calling, which fails on Open WebUI 0.10+ (string annotations of
workspace tools, MCP/OpenAPI callables all named tool_function, direct tools
without a callable) and ignored API clients' tools.

1.18.0 declares the tools from body["tools"] (name mapping, JSON Schema
clean-up, AFC disabled) and ends the round when Gemini calls a function:
the calls go back as OpenAI tool_calls with the thought signatures as
reasoning_details, Open WebUI runs the tools (built-in, workspace, MCP,
OpenAPI, terminal, direct, approval) and calls the pipe again, and the
history is converted back into function_call / function_response contents.
API clients get tool_calls in stream and non-stream answers. Gemini 3 on the
Gemini API sends Search grounding together with functions; elsewhere
grounding takes precedence. Requires google-genai>=1.68.0.

+semver: minor

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Gemini 1.18.0 moves tool calls into Open WebUI's tool loop; these groups
pin that contract down and show what 1.17.0 does instead. New groups
gemini.tools (browser path: built-in, workspace, OpenAPI, MCP and direct
tools, approval, rounds, signatures across turns, grounding with tools)
and gemini.toolsapi (API clients: tool_calls, finish_reason, history,
tool_choice, name mapping, schemas). The Gemini mock answers a MOCKTOOLS
directive with function calls, validates call/response pairing and
signatures like the API and records what the pipe sent; tool server
mocks (OpenAPI :9111, MCP :9112) are outside E2E_MOCK_FAULT. The browser
harness now sends the Socket.IO namespace sid, heartbeats and answers
execute:tool. Nine gemini-tools-* known bugs of 1.17.0, gated on 1.18.0,
carry mock_ok=True evidence so the meta-test stays at 0 KNOWN.

+semver: none

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tive-tool-calling

+semver: none

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-ups for native tool calling (1.18.0, unreleased):
- the stored model content of a grounding + tool round (toolCall /
  toolResponse parts) is replayed only within its own turn and never on
  Vertex AI; later turns are rebuilt from the function calls and their
  signatures, so a message with web search switched off (or a switch to
  Vertex, where google-genai raises for tool_call parts) keeps working
- tool names are checked with fullmatch: "$" let a name with a trailing
  newline through unchanged, and Gemini rejects the whole request
- numeric tool call ids and a tool call "function" that is not an object
  no longer abort the request
- docs: offline Open WebUI 0.9.0-0.11.3 cannot install google-genai>=1.68.0
  and switches the function off on save; how to avoid it

+semver: none

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The mutation review left 23 mutants of google_gemini.py alive. New or
tightened checks for each: response order of parallel calls, no placeholder
signature on Gemini 2.x, keyword-named properties and numeric
exclusiveMinimum, MALFORMED_FUNCTION_CALL / UNEXPECTED_TOOL_CALL on both API
paths, the stored-content item next to the signature item, the sources of
both grounding rounds (the mock now cites a separate tool-round source),
text answers to API requests with tools, GOOGLE_STREAMING_ENABLED=false on
both paths, thoughts of a non-streamed tool round, no reasoning_content for
API clients, older-turn edge cases, the default_api. prefix,
provider_specific_fields, tool_choice with an undeclared name and with
grounding, and a tool result with an image (workspace tool make_image).
Checks for the review fixes: the next turn without web search after a
grounding round, a tool name with a trailing newline (mock name rules use
fullmatch too) and numeric ids / a non-object function.

The new checks that fail on 1.17.0 carry the matching gemini-tools-*
marker, so main stays at 0 FAIL until the merge; baseline counts in
CLAUDE.md, README and docs/testing.md describe the branch.

+semver: none

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Harness and fix land in the same PR, so the nine gemini-tools-* markers
(gated on google_gemini.py 1.18.0) would only add "drop marker" noise
after the merge. Remove them with their known= tags, the known= / since=
plumbing of the tool groups' Ctx.check and harness/known_gemini.py with
its re-export in known.py. The 42 checks stay and simply have to pass.
CLAUDE.md and README.md take the check-count lines of #190 (b1c393a);
docs/testing.md loses the 1.17.0 column and the marker table.

Evidence of what failed on 1.17.0 (main 4d09a55, v0.11.4-slim,
--ref origin/main --only 'gemini.(tools|toolsapi)': 11 PASS / 0 FAIL /
42 KNOWN):
- gemini-tools-afc (20): tools.builtin, builtin-nonstream, multiturn,
  multiturn-other-model, multiturn-back, parallel, parallel-split,
  rounds, rounds-nosig, text-before, thinking, thinking-off,
  image-result, approval, approval-reject, approval-parallel, unknown,
  grounding3-next-turn, nostream, log
- gemini-tools-annotations (1): tools.workspace (gemini-tools-afc on
  v0.11.3-slim, where google-genai 1.66.0 stops after the calls)
- gemini-tools-duplicate (1): tools.openapi
- gemini-tools-mcp (1): tools.mcp
- gemini-tools-direct (1): tools.direct
- gemini-tools-api (9): toolsapi.stream, nonstream, nostream,
  tool-choice, tool-choice-grounding, names, default-api, schema, noid
- gemini-tools-history (5): toolsapi.continuation, continuation-psf,
  continuation-nosig, history-edge, odd-shapes
- gemini-tools-grounding (2): tools.grounding3, grounding25
- gemini-tools-malformed (2): tools.malformed, toolsapi.malformed

After the drop, with 1.18.0: all suites on v0.11.4-slim 339 PASS /
0 FAIL / 1 KNOWN (infomaniak-name-prefix), normal and --strict-known,
no obsolete markers; gemini on v0.11.3-slim 129 / 0 / 0 in both modes;
meta-test (main 3ff6cf9, E2E_MOCK_FAULT=500) 39 PASS / 241 FAIL /
0 KNOWN.

+semver: none

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The mock-based groups cannot confirm what the Gemini API accepts.
tests/e2e/smoke/gemini-tools.sh runs one scenario per spec risk
R1-R15 against generativelanguage.googleapis.com through a recording
pass-through proxy, with bounded retries, a request estimate, evidence
per risk and key redaction; --dry-run-mock runs the same scenarios
against the e2e mock. run.sh can now be sourced for its container
helpers, and the browser session no longer lets engine.io take over
SIGINT, which hid interrupted runs.

+semver: none

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main brought the Azure 2.8.1 On Your Data retirement warning and its four
oyd.notice.* checks (#190). docs/testing.md keeps this branch's gemini row,
the Native tool calling section and the smoke section, takes main's azure
row, and sets the counts from a run of the merged tree on v0.11.4-slim in
strict known mode: gemini 129, azure 75, n8n 51, infomaniak 32, filters 57
= 344 checks, 343 PASS / 1 KNOWN.

+semver: none

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… smoke runner

A bad GOOGLE_API_KEY failed the model listing, so the setup error only said
that both models were missing. It now lists the proxy's failing non-generate
requests (e.g. "GET /v1alpha/models http=400 x4: API key not valid"), in the
output and in summary.md, still with exit code 2.

The printed request count was only an estimate. The proxy now enforces a hard
cap (--max-requests, default twice the printed maximum): a generate request
beyond it is answered with HTTP 429 locally and never forwarded, the scenario
is not retried, the remaining risks are SKIP, and summary.md / smoke.json
report budget_hit with a BUDGET FAIL row. The start line labels the number as
an estimate and prints the cap.

The Vertex credentials file holds secrets too: refresh_token, client_secret,
private_key_id and the base64 lines of private_key are now part of the
runner's SECRETS check (server log, mocks output, runner output) and of the
host scan, which extracts the same values without a JSON parser. With --keep
the script says that the container holds the file in plain text.

R1 counts make_image in the workspace group (27 built-ins + 8 tools
declared). docs/testing.md also gets the meta-test count of the merged
suites (42 PASS / 242 FAIL / 0 KNOWN).

+semver: none

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SECRETS row was built before smoke.json, upstream.jsonl and
summary.md were redacted, so a secret found only in those files never
failed the run. The files are now rendered and redacted first; a hit
marks SECRETS as FAIL and the files are rendered again.

The host-side Vertex secret extraction also strips JSON-escaped \r, so
a PEM key with CRLF line endings yields the same values as the runner.
The docs say that a budget or bad-key run also fails the LOG row.

+semver: none

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Since 1.15.2 a generated image is attached to the answer only as an Open
WebUI file. Open WebUI does not pass the files of assistant messages to
a pipe, so an edit request in the same chat reached Gemini as text only
and Nano Banana drew a new, unrelated image (#194).

For a saved chat of the requesting user the image path now loads the
message chain from Open WebUI's database (get_messages_map_by_chat_id
and get_message_list, like Open WebUI's load_messages_from_db, both
available since 0.9.0) and sends the image files of earlier user and
assistant messages, plus markdown image links, as history images. API
clients, temporary and channel chats and errors keep the request's
messages; a guided regeneration keeps the images of the regenerated
message. Earlier model turns are not replayed, so no thought signatures
are needed.

IMAGE_HISTORY_MAX_REFERENCES now drops the oldest history images first
and never the current message's images, so the image being edited stays
in a long chat. Open WebUI files are read only for their owner or an
admin: until now any file id in a message was read.

The new e2e group gemini.imgedit covers the two-turn edit on
gemini-3.1-flash-image and gemini-nano-banana-2.1, an upload, guided
regeneration, the limit, temporary chats and foreign file ids.

+semver: none

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…history

The review of the #194 fix found that the saved-chat image history went
wrong in a few places:

- Open WebUI copies the chat's metadata into its background tasks, so the
  follow-up task of an image model got the chat's images. Tasks now keep
  using the request's messages.
- Guided regeneration was detected by comparing texts, so a user message
  with only an image, or with a text contained in the guidance, lost its
  images. It is now detected from the message structure: Open WebUI adds
  the guidance as one more user message after the saved one.
- Deduplication kept the oldest copy of an image and did not compare the
  history with the current message, so an image attached again was sent
  twice or dropped as old. The history is now read newest first, each
  image once, and not again when the current message has it.
- Every earlier image was read and re-encoded on the event loop before the
  limit applied. Only the images that fit are read now, and decoding runs
  in a worker thread.
- The refusal warning for another user's file no longer logs its id, and
  the docs say that Open WebUI 0.9.0 to 0.9.5 itself puts any file id named
  in a user message into the request.

The imgedit tests now also cover the follow-up task, guided regeneration
of an image-only message, another image per turn (a new mock trigger), the
default function calling mode, older saved image forms, a database error,
an admin in another user's chat, IMAGE_DEDUP_HISTORY=false and file access
on the Veo path; images.history checks the cap on current images.

+semver: none

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main brought Azure AI Foundry 3.0.0 with the pipeline's own Azure AI
Search retrieval (#195): mocks/mock_search.py on 127.0.0.1:9106, the
managed identity variables in run.sh and the azure rag group.

The merge has two bases (3ff6cf9, 7ba3569); the Azure files, which this
branch never changed, are taken from main. config.py and serve_all.py
keep search 9106 next to tools 9111 and MCP 9112. BrowserSession.chat()
keeps tool_ids, tool_servers, extra_body, until and user_files next to
main's files and extra; parse_sse() collects the tool calling fields
and main's after_done counters. docs/testing.md keeps the gemini row,
the native tool calling section and the smoke section, takes main's
azure row and rag paragraphs, and combines the counts of both sides
(re-measured after the next merge).

+semver: none

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…/gemini-native-tool-calling

Brings Time Token Tracker 2.7.0 with the Logs Ingestion API (#191),
merged with main 4b80e24, and its filters suite (mock_la.py with the
managed identity endpoints on 127.0.0.1/127.0.0.2:9107).

config.py keeps search 9106 with #191's comment on 9105/9107 next to
tools 9111 and MCP 9112. docs/testing.md keeps the native tool calling
section, takes #191's filters row, PROBE_ENV / PROBE_LOG, mock_la setup
and output file rows, and combines the counts (filters 101, total 531);
the layout lists :9106, :9111, :9112 and mock_la's :443, :9105, :9107.

+semver: none

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ogs Ingestion API

Measured on the merged tree (main 4b80e24 + #191 + #193): 531 checks,
530 PASS / 1 KNOWN on v0.11.4-slim (normal and strict known mode) and
on v0.11.3-slim, about 19 minutes per full run. The sample output, the
timing paragraph, the counts header and the meta-test row (57 PASS /
370 FAIL / 0 KNOWN with main's files) now say so.

+semver: none

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s fail fast

+semver: none

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
+semver: none

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant