Skip to content

fix(gateway): stop dropping caller tools and provider response fields - #3569

Merged
NVJKKartik merged 4 commits into
devfrom
fix/TH-8722-stop-dropping-tools-and-fields
Oct 21, 2026
Merged

NVJKKartik merged 4 commits into
devfrom
fix/TH-8722-stop-dropping-tools-and-fields

Conversation

@NVJKKartik

@NVJKKartik NVJKKartik commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

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 no type to declare. The translation skipped any entry that was not a function, and with no function declarations left it omitted tools entirely — 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.Raw escape hatch did not catch it. Tool.UnmarshalJSON kept the caller's bytes only when a type was 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 sends citations and search_results beside the choices and its own counters inside usage; none of it reached the caller. Streaming dropped the same fields.

The asymmetry is the argument: ChatCompletionRequest has 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.go captures and replays unknown fields for ChatCompletionResponse, StreamChunk and Usage. 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.
  • Gemini forwards googleSearch, googleSearchRetrieval, urlContext and codeExecution, each in its own tools entry beside the caller's functions, 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 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.
  • A request whose tool entries are all unservable is refused with 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_20250305 alongside their own functions would have the request fail on whichever member is Gemini — and 400 is not in DefaultFailoverStatusCodes (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

  1. A request whose tools are all unservable now fails instead of being answered without them.
  2. A declared type other than "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.
  3. A tool entry naming no function now reaches the provider as the caller wrote it. {"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.
  4. Responses carry provider fields they did not carry before. A client that rejects unknown fields strictly will see more of them.

Tests

19 cases. Every behaviour case was run against origin/dev first and fails there for the right reason:

  • google_search reaches the provider — on dev: no tools were sent at all; body was [contents generationConfig], the reported bug verbatim
  • all four tools, both spellings, a retrieval config forwarded as written, and the {"type": "google_search"} form (6 cases)
  • functions and a server tool travel together in two entries — on dev: 1 entry
  • an all-unservable request is refused and the upstream is never called (3 cases)
  • an unservable tool beside a surviving one does not fail the request (2 cases; the function variant passes on dev too, since the outcome there is the same drop — it is a guard against the refusal firing, not a regression test)
  • a nameless function entry keeps the caller's bytes — on dev: {"type":"function","function":{"name":""}}
  • grounding sources become annotations with the URL, title and text extent — on dev: dropped
  • Perplexity's citations, search_results and extra usage counters survive a round trip — on dev: every one dropped
  • the same for a stream chunk
  • guards that pass on both trees: a response with nothing extra serializes byte-for-byte as before, a reply with no grounding carries no annotations key, and a known key is never captured or merged back

Full gateway suite green, 82 packages. go vet clean on the packages touched. gofmt clean on every file in the diff. Merged together with #3568 and #3570 on top of dev: no conflicts, build clean, 83 packages green.

(internal/models/errors_test.go has a pre-existing go vet complaint about a deliberate json.Unmarshal(..., nil); untouched here.)


E2E: exempt (harness-gap the e2e stack cannot configure a Gemini provider or a tool policy)

e2e/lib/managed-mock.ts admits only the logging, providers and server sections and pins the provider set to openai alone with api_format: openai against the deterministic mock. So no Gemini provider can exist in the e2e stack, no tool_policy section 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 real toolpolicy.ProcessRequest.

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.
@NVJKKartik
NVJKKartik force-pushed the fix/TH-8722-stop-dropping-tools-and-fields branch from e4f4384 to 07846f3 Compare October 21, 2026 08:38

@Tanmaycode1 Tanmaycode1 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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:

  1. Configure tool_policy with enabled: true, default_action: reject, and allow: [get_weather].
  2. Send tools: [{"type":"code_execution","function":{"name":"get_weather"}}].
  3. The policy returns no error because get_weather is allowed, but the provider forwards tools: [{"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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. 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.
  2. 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 Tanmaycode1 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@NVJKKartik
NVJKKartik merged commit 674f2f3 into dev Oct 21, 2026
18 checks passed
@NVJKKartik
NVJKKartik deleted the fix/TH-8722-stop-dropping-tools-and-fields branch October 21, 2026 15:24
praxstack pushed a commit to praxstack/future-agi-future-agi that referenced this pull request Oct 22, 2026
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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants