Repository navigation
fix(gateway): stop dropping caller tools and provider response fields - #3569
Conversation
Two silent drops on /v1/chat/completions, both found the same way: the
request was accepted, something in it was discarded, and the caller was
billed for a reply that could not do the job.
A tool Gemini runs itself is written as a bare {"google_search": {}}, with no
type to declare. The translation skipped any entry that was not a function,
so the request went upstream with no tools at all. Asked to answer from search
results it was never given the tool to fetch, the model returned nothing:
HTTP 200, empty content, finishReason STOP, zero output tokens, prompt tokens
billed. The Tool.Raw escape hatch did not catch it either, because it only
kept the caller's bytes when a type was present.
Any response field outside the canonical struct was dropped. Perplexity sends
citations and search_results beside the choices, and its own counters inside
usage; none of it reached the caller. The request path has kept unknown fields
and forwarded them since it was written, so the gateway promised pass-through
in one direction only.
- models/passthrough.go captures and replays unknown fields for
ChatCompletionResponse, StreamChunk and Usage. The known keys are read from
the struct tags, not listed by hand, so a new field cannot drift out of its
own capture. A key the struct names always wins: a stale captured copy would
undo whatever the gateway normalized.
- Gemini forwards googleSearch, googleSearchRetrieval, urlContext and
codeExecution, each in its own tools entry, in either spelling, carrying the
caller's own config so a retrieval config survives.
- Grounding sources come back as url_citation annotations, the shape OpenAI's
own web search uses and the anthropic provider already builds. Without them
a grounded answer arrives with no way to see where it came from.
- A request whose every tool entry Gemini cannot serve is refused with 400
unsupported_tool, before a concurrency slot is taken. One that keeps some
tools goes on, with the rest logged: a model alias or failover group can span
providers, 400 is not a failover status, and failing the whole request over a
tool meant for another member would be worse than the drop.
A declared type other than "function" is now taken at its word even when the
entry also names a function. OpenAI's older assistants types do that, and
declaring one to the model as callable would have it call a function nobody
implements.
Two behaviour changes. A request whose tools are all unservable now fails
instead of being answered without them. And a tool entry naming no function
reaches the provider as the caller wrote it rather than as an empty function
declaration, which changes what openai and anthropic send upstream for a
malformed entry.
e4f4384 to
07846f3
Compare
Tanmaycode1
left a comment
There was a problem hiding this comment.
Confirmed one blocking correctness/security finding on this head: newly enabled Gemini server tools can bypass tool permissions. The inline comment includes the reproduction and suggested fix. Verified with actual policy/provider components and a local HTTP upstream; the same safety assertions pass on base 03fe69d and fail on this head. Existing gateway tests and scoped race checks passed, but do not cover this scenario.
| continue | ||
| } | ||
| var entry geminiToolDeclarations | ||
| geminiServerTools[key](&entry, config) |
There was a problem hiding this comment.
[P1 — blocks merge] Enforce tool permissions against the capability actually forwarded (§06; introduced by this PR).
The tool-policy plugin checks only tool.Function.Name, while this new branch chooses the executable capability from tool.Type or the raw tool key. Those identities can disagree.
Confirmed reproduction using the actual toolpolicy.ProcessRequest and Gemini Provider.ChatCompletion, with a local HTTP upstream:
- Configure
tool_policywithenabled: true,default_action: reject, andallow: [get_weather]. - Send
tools: [{"type":"code_execution","function":{"name":"get_weather"}}]. - The policy returns no error because
get_weatheris allowed, but the provider forwardstools: [{"codeExecution":{}}].
Also reproduced with deny: [google_search] and a bare {"google_search":{}} entry, and with deny: [code_execution]: the denied capabilities still reach the upstream because their function names are empty.
Identical safety tests pass on base 03fe69df9, where these capabilities are not forwarded, and fail on this PR. This is a newly reachable policy bypass, not just a missing annotation or test.
Please normalize the actual function/server-tool identity before policy checks, reject contradictory declarations, and ensure the policy and provider use that same identity. Add regression tests for allowlists and denylists, including both supported spellings and global/org/key policy scopes. These were local integration tests; no live provider execution was required. Also check the function-name-only tool-permission guardrail when making the identity change.
There was a problem hiding this comment.
Re-review of a9cea5a: the original contradictory-type case now returns 400 contradictory_tool, but the capability-enforcement blocker is not fully resolved (§06/§09).
Two bypasses are still confirmed through the actual permission check and Gemini provider with a local HTTP upstream:
- Built-in aliases are not normalized before policy lookup. With deny: [code_execution], both {"codeExecution":{}} and {"type":"codeExecution"} pass the policy and reach Gemini as {"codeExecution":{}}. Tool.Capability() returns the supplied spelling (models/request.go:189-196), while the provider later treats snake_case and camelCase as the same capability. Tested all four recognized capability pairs, both spelling directions, reject/strip, and bare/typed inputs: 32 denied-capability cases fail on head and pass on base.
- The separately configurable tool_permission guardrail still reads Function.Name (guardrails/toolperm/toolperm.go:80). With the independent tool_policy plugin disabled, its denylist for code_execution permits both bare and typed entries; an allowlist for get_weather also permits the original contradictory entry. All three pass the guardrail and forward codeExecution on head; none forward that capability on base.
Please normalize recognized built-in identities consistently on both the tool and policy sides, preserve ordinary function-name semantics, and apply that identity/contradiction contract to the tool-permission guardrail too. Add the cross-spelling and guardrail-path regressions. These are remaining P1 security blockers, so I am holding approval.
The full gateway suite and targeted race tests pass, as does the E2E declaration gate; the additional reproductions expose paths those tests do not cover.
Review found a policy bypass this PR introduced. The tool-policy plugin read
tool.Function.Name, while the new Gemini branch chose what to forward from
tool.Type or the entry's own key. Those two identities can disagree, so:
- {"type":"code_execution","function":{"name":"get_weather"}} passed a policy
allowing get_weather, and forwarded codeExecution.
- a bare {"google_search":{}} names no function, so a denylist on
google_search never matched it, and an allowlist naming it refused it —
the policy saw tool "".
models.Tool.Capability is now the one answer to "what does this entry ask the
provider to do", and both the policy and the provider read it. A declared
non-function type wins, because that is what a provider forwards. The gemini
translation no longer derives the identity itself; deriving it twice is how
the two drifted apart.
An entry that declares a non-function type and a function name is refused with
400 contradictory_tool rather than resolved. Enforcing either identity would
leave the other unchecked, and silently ignoring half of what a caller wrote
is the defect this PR exists to fix.
Tests: 8 policy cases over allowlists, denylists, both spellings, type-named
and bare-key entries, plus the contradiction; and a table pinning Capability
against every tool shape. Against this PR's previous head, four cases let a
denied capability reach the provider and one refused an allowed one.
…icy paths
Review found the bypass only half closed. Capability returned the spelling the
caller supplied, while a provider treats snake_case and camelCase as the same
capability — so deny: [code_execution] let {"codeExecution":{}} reach the
upstream. And a second enforcement point, the tool_permission guardrail, still
read Function.Name, so a capability naming no function was invisible to it
whether the tool_policy plugin was enabled or not.
models.CanonicalCapability is now the one identity. Every side of an
authorization decision resolves through it: the identity read off a request,
the global allow and deny lists, the per-org lists, and the per-key CSV lists
from auth metadata. Normalizing one side alone leaves the other spelling
unmatched, which is the bypass rather than an inconsistency. A name it does
not recognize is returned unchanged, so ordinary function-name semantics are
untouched.
The guardrail matches globs rather than exact names, so canonicalizing alone
would have stopped a pattern written the other way — "*Execution" would miss
code_execution. It tests every spelling of the capability instead, which keeps
a pattern written either way working and leaves a denylist failing closed.
The gemini map is now keyed by the canonical name, since the spelling is
resolved before it is consulted, and the caller's config object is found by
whichever spelling they wrote.
Tests: the full spelling matrix on both paths — 4 capability pairs x both
configured/requested directions x bare and typed x reject and strip — plus the
contradictory entry against an allowlist, and ordinary function allow/deny
either side of the change. 66 subtests fail on this PR's previous head.
Review found the canonicalization reaching too far. It resolved every tool name through the alias table, so an allowlist holding code_execution also admitted a custom function named codeExecution, and the reverse. Those are distinct application functions, and an application may legitimately call one of its own "codeExecution". Two spellings of google_search are one capability. Two spellings of a function name are two different functions. So the kind of the entry decides whether alias equivalence applies: - Capability returns a function's name exactly as written, and canonicalizes only a declared non-function type or a bare capability key. - A policy list is now two sets. exact holds the operator's names as written and is what a function is matched against; builtin holds the canonical form of the entries naming a provider-executed tool and is what such a tool is matched against. Merging them is what let a rule about one reach the other, and my first attempt at this leaked in the opposite direction — a list naming codeExecution matched a custom function called code_execution. - The tool_permission guardrail carries the kind with each name rather than flattening to strings. A function matches its exact name or a glob over it; a built-in matches any spelling, which keeps "*Execution" working. Response tool calls are function calls, so they match exactly. Tests: the custom-function collisions in both directions on both paths, response tool calls matched exactly with a glob still applying, and a function name surviving unchanged through Capability. 6 subtests fail on the previous head. The original bypass coverage is unchanged — 32 denied-capability cases still fail on the head before canonicalization.
Tanmaycode1
left a comment
There was a problem hiding this comment.
No blockers now. Rechecked the final permission fix: custom function names remain exact in tool policy and request/response guardrails, while built-in aliases still match correctly. All six previously failing custom-function allowlist reproductions now pass, along with the original alias/contradiction/guardrail bypass cases. Full gateway suite: 82 packages passed, 23 tests skipped for unavailable external dependencies. Targeted race tests (count=2), touched-package vet, generated contracts, formatting and the E2E coverage declaration pass. Approving; remaining GitHub checks must complete before merge.
Gemini's provider-executed tools were skipped for not being functions, so the request went upstream with no tools and the model answered without the capability it was asked for. They are forwarded now, in either spelling, with grounding sources returned as url_citation annotations. Response fields outside the canonical schema pass through. Tool policy and the tool_permission guardrail enforce the capability that is actually forwarded, with alias equivalence applied to built-in tools only. Cherry-picked from future-agi#3569 on dev (674f2f3).
Two silent drops on
/v1/chat/completions. Both were found the same way: the request was accepted, something in it was discarded, and the caller was billed for a reply that could not do the job.The problem
A tool the provider runs itself was dropped. Gemini's provider-executed tools are written as a bare
{"google_search": {}}, with notypeto declare. The translation skipped any entry that was not a function, and with no function declarations left it omittedtoolsentirely — the request went upstream with no tools at all. Asked to answer from search results it was never given the tool to fetch, the model returned nothing: HTTP 200, empty content,finishReason: STOP, zero output tokens, prompt tokens billed, four runs in a row.The
Tool.Rawescape hatch did not catch it.Tool.UnmarshalJSONkept the caller's bytes only when atypewas present and not"function", so an entry with no type at all reached the provider as an empty function and was skipped. The Anthropic path forwards its server tools through exactly that field, so the pattern existed and Gemini could not use it.Any response field outside the canonical struct was dropped. The gateway rebuilds every response on
models.ChatCompletionResponse, so a field it does not name is lost. Perplexity sendscitationsandsearch_resultsbeside the choices and its own counters insideusage; none of it reached the caller. Streaming dropped the same fields.The asymmetry is the argument:
ChatCompletionRequesthas kept unknown fields and forwarded them to providers since it was written. The gateway promised pass-through in one direction only.The change
models/passthrough.gocaptures and replays unknown fields forChatCompletionResponse,StreamChunkandUsage. The known keys are read from the struct tags rather than listed by hand, so a field added to a struct cannot drift out of its own capture — the request side's hand-written list is the hazard being avoided. A key the struct names always wins: a stale captured copy would undo whatever the gateway normalized.googleSearch,googleSearchRetrieval,urlContextandcodeExecution, each in its owntoolsentry beside the caller's functions, in either spelling, carrying the caller's own config so a retrieval config survives.url_citationannotations — the shape OpenAI's own web search uses, and the one the Anthropic provider already builds from its citations. Without this, enabling the tool would return grounded text with no way to see where it came from, which is the reason for asking.400 unsupported_tool, before a concurrency slot is taken.Why the refusal is narrow
My first version refused any unservable entry. That breaks a configuration the gateway offers: a model alias or failover group can span providers, so a caller sending Anthropic's
web_search_20250305alongside their own functions would have the request fail on whichever member is Gemini — and400is not inDefaultFailoverStatusCodes(429, 500, 502, 503, 504), so there would be no falling through to the next provider either. It would turn a silent drop into an outright failure for a working setup.So the refusal fires only when nothing survives, which is the reported case and the only one where silence is unambiguously wrong: the request would reach the provider with no tools and the model would answer as if the capability had never been asked for. When some tools survive, the model still has something to work with and the dropped entries are logged at WARN.
Behaviour changes to know about
"function"is taken at its word, even when the entry also names a function. OpenAI's older assistants types do that ({"type": "retrieval", "function": {...}}). The obvious reading of my first patch declared such an entry to the model as a callable function, which would have it call something nobody implements.{"type": "function"}with no name used to be re-marshalled as{"type":"function","function":{"name":""}}; it now travels verbatim. This changes what the openai and anthropic providers send upstream for a malformed entry — both upstreams reject it either way, but the error text comes from the caller's own bytes. There is a test pinning this.Tests
19 cases. Every behaviour case was run against
origin/devfirst and fails there for the right reason:google_searchreaches the provider — ondev:no tools were sent at all; body was [contents generationConfig], the reported bug verbatim{"type": "google_search"}form (6 cases)dev: 1 entrydevtoo, since the outcome there is the same drop — it is a guard against the refusal firing, not a regression test)dev:{"type":"function","function":{"name":""}}dev: droppedcitations,search_resultsand extrausagecounters survive a round trip — ondev: every one droppedannotationskey, and a known key is never captured or merged backFull gateway suite green, 82 packages.
go vetclean on the packages touched.gofmtclean on every file in the diff. Merged together with #3568 and #3570 on top ofdev: no conflicts, build clean, 83 packages green.(
internal/models/errors_test.gohas a pre-existinggo vetcomplaint about a deliberatejson.Unmarshal(..., nil); untouched here.)E2E: exempt (harness-gap the e2e stack cannot configure a Gemini provider or a tool policy)
e2e/lib/managed-mock.tsadmits only thelogging,providersandserversections and pins the provider set toopenaialone withapi_format: openaiagainst the deterministic mock. So no Gemini provider can exist in the e2e stack, notool_policysection can be configured, and the mock returns a fixed OpenAI-shaped body with no provider-specific fields — which is every surface this PR touches. Covered by Go tests: provider-level assertions on the body sent upstream, and policy-level assertions through the realtoolpolicy.ProcessRequest.