Skip to content

[BUG]: LiteLlm.capabilities hardcodes output_schema_and_tools=True, bypassing the SetModelResponseTool workaround #6954

Closed

Description

@yrangana

Description

LiteLlm.capabilities returns output_schema_and_tools=True unconditionally, regardless of which model it wraps. ADK trusts that and applies response_schema alongside the tool declarations, so SetModelResponseTool never engages. On models that cannot honour both constraints the agent loops, calling tools repeatedly instead of producing structured output.

This is the same failure as #3413 and #5054, but on a path none of the existing work covers:

Filing per the request on #5057: "the repro @wuliang229 asked for is what a version gate needs first... open a new issue if you see this happen again."

Environment

  • google-adk 2.8.0
  • litellm 1.98.0
  • Python 3.13.14
  • Model: openrouter/google/gemini-3.1-flash-lite (Gemini 3.1 Flash Lite via OpenRouter)

Reproduction

Needs an OpenRouter key in OPENROUTER_API_KEY. Any provider LiteLLM can reach should do; the model family matters more than the provider (see the note on model dependence below).

import asyncio
from pydantic import BaseModel, Field
from google.adk import Agent
from google.adk.models.lite_llm import LiteLlm
from google.adk.runners import InMemoryRunner
from google.genai import types

MODEL = "openrouter/google/gemini-3.1-flash-lite"

class CityTime(BaseModel):
    city: str = Field(description="City name")
    time: str = Field(description="Local time")

def get_current_time(city: str) -> dict:
    """Returns the current time in a specified city."""
    return {"city": city, "time": "10:30 AM"}

agent = Agent(
    model=LiteLlm(MODEL),
    name="repro",
    instruction="Use get_current_time to look up the time, then answer.",
    tools=[get_current_time],
    output_schema=CityTime,
)

async def main():
    runner = InMemoryRunner(agent=agent, app_name="repro")
    await runner.session_service.create_session(
        app_name="repro", user_id="u", session_id="s")
    tool_calls = 0
    async for ev in runner.run_async(
        user_id="u", session_id="s",
        new_message=types.Content(role="user",
                                  parts=[types.Part(text="What time is it in Sydney?")]),
    ):
        if ev.content and ev.content.parts:
            for p in ev.content.parts:
                if p.function_call:
                    tool_calls += 1
                    print(f"  tool call {tool_calls}: {p.function_call.name}")
    print(f"total tool calls: {tool_calls}")

asyncio.run(main())

Observed: get_current_time is called repeatedly with identical arguments before the agent settles. Four runs of this exact code gave 4, 12, 18 and 19 tool calls. It does eventually produce valid output, but the count is neither stable nor bounded, and on some runs it did not terminate within several minutes.

Adding output_schema_and_tools=False (via the subclass below) makes it 2 function calls, every run: get_current_time plus set_model_response.

Supplementary: confirming the cause without API calls

This inspects the outgoing request and short-circuits before anything is sent, so it needs no key and no credit. Useful for verifying a fix quickly.

import asyncio
from pydantic import BaseModel
from google.adk import Agent
from google.adk.models.lite_llm import LiteLlm
from google.adk.models.base_llm import LlmCapabilities
from google.adk.models.llm_response import LlmResponse
from google.adk.runners import InMemoryRunner
from google.genai import types

MODEL = "openrouter/google/gemini-3.1-flash-lite"

class CityTime(BaseModel):
    city: str
    time: str

def get_current_time(city: str) -> dict:
    """Returns the current time in a specified city."""
    return {"city": city, "time": "10:30 AM"}

class SchemaSafeLiteLlm(LiteLlm):
    @property
    def capabilities(self) -> LlmCapabilities:
        return LlmCapabilities(output_schema_and_tools=False)

def spy(callback_context, llm_request):
    cfg = llm_request.config
    schema_set = bool(getattr(cfg, "response_schema", None)
                      or getattr(cfg, "response_json_schema", None))
    names = [fd.name
             for t in (getattr(cfg, "tools", None) or [])
             for fd in (getattr(t, "function_declarations", None) or [])]
    print(f"  response_schema_set={schema_set}  tools_offered={names}")
    return LlmResponse(content=types.Content(role="model", parts=[types.Part(text="{}")]))

async def inspect(label, model):
    print(f"\n{label}  output_schema_and_tools={model.capabilities.output_schema_and_tools}")
    agent = Agent(model=model, name="probe", instruction="x",
                  tools=[get_current_time], output_schema=CityTime,
                  before_model_callback=spy)
    runner = InMemoryRunner(agent=agent, app_name=label)
    await runner.session_service.create_session(
        app_name=label, user_id="u", session_id="s")
    async for _ in runner.run_async(
        user_id="u", session_id="s",
        new_message=types.Content(role="user", parts=[types.Part(text="time in Sydney?")]),
    ):
        pass

async def main():
    await inspect("stock LiteLlm", LiteLlm(MODEL))
    await inspect("SchemaSafeLiteLlm", SchemaSafeLiteLlm(MODEL))

asyncio.run(main())

Output:

stock LiteLlm  output_schema_and_tools=True
  response_schema_set=True   tools_offered=['get_current_time']

SchemaSafeLiteLlm  output_schema_and_tools=False
  response_schema_set=False  tools_offered=['set_model_response', 'get_current_time']

Cost

Each retry resends the whole conversation, so the waste is worse than the call count suggests. Measured for the single question above, via OpenRouter:

generations input tokens cost
schema only, no tools 1 19 $0.000071
schema + tools, stock LiteLlm 13 5,810 $0.001848

Input tokens grow ~300x against ~26x on cost, so this degrades sharply with a realistic tool payload rather than the toy one used here. The failure is also silent: valid output is eventually returned, so nothing surfaces except the bill.

Expected behaviour

LiteLlm should not assert the capability on behalf of every model it wraps. When the wrapped model cannot pair response_schema with tools, ADK's existing SetModelResponseTool workaround should engage, as it does on the native path.

With the capability declared False, behaviour is correct and stable: 2 function calls per turn (get_current_time plus set_model_response), and a second conversation turn still calls the tool rather than answering from history.

Root cause

src/google/adk/models/lite_llm.py:3075

@property
@override
def capabilities(self) -> LlmCapabilities:
    # LiteLLM reconciles tools + response_format per provider: providers with
    # native support get both passed through, and the rest are converted to a
    # json tool call with tool_choice enforcement.
    return LlmCapabilities(output_schema_and_tools=True)

src/google/adk/flows/llm_flows/basic.py:137

if not agent.tools or model.capabilities.output_schema_and_tools:
    llm_request.set_output_schema(agent.output_schema)

The comment states the reasoning: LiteLLM is expected to reconcile the two per provider. For this provider and model it does not, and both constraints reach the model raw, as the reproduction shows.

By contrast the native path is conservative. gemini_output_schema_and_tools requires Vertex AI and a Gemini model id, so on other backends it correctly returns False and the workaround engages.

Note on model dependence

The model matters too, consistent with the table in #5054: flash models prefer calling tools over set_model_response, while gemini-3.1-pro-preview succeeds on the first call. The gemini-3.1-flash-lite used here is in the susceptible family. That is an argument for LiteLlm being conservative by default rather than asserting support for every wrapped model, since it cannot
know which family it is proxying.

Possible fixes

  1. Default LiteLlm.capabilities to output_schema_and_tools=False, so the reliable SetModelResponseTool path is used unless a user opts out. Safe, at the cost of one extra function call for providers that could have handled it natively.
  2. Make it constructor-configurable, e.g. LiteLlm(model, output_schema_and_tools=False), so users can declare it without subclassing.

Deriving it from LiteLLM's own metadata does not work. LiteLLM reports both capabilities independently, and has no API for the combination:

litellm.supports_response_schema(model="openrouter/google/gemini-3.1-flash-lite")   # True
litellm.supports_function_calling(model="openrouter/google/gemini-3.1-flash-lite")  # True

Both are True while the combination still loops, so the assumption in the current code comment cannot be rescued by consulting LiteLLM.

Happy to open a PR — combining 1 and 2 (a constructor argument defaulting to False) is a small diff, needs no network to test, and makes LiteLlm consistent with the conservative native gate. Glad to keep the default at True instead if preserving current behaviour matters more.

Related

Activity

  1. yrangana commented on Aug 30, 2026

    @yrangana
    Author

    Deriving the capability from LiteLLM is a dead end: supports_response_schema and supports_function_calling both return True while the combination still loops. A constructor argument defaulting to False works.

    Happy to test a branch against openrouter/google/gemini-3.1-flash-lite, which reproduces reliably.

  2. yrangana commented on Aug 31, 2026

    @yrangana
    Author

    Tested the latest 1.x as well: the same behaviour is present in google-adk 1.39.1, so this is not limited to the 2.x capability system. Note 1.39.1 was published after 2.0.0, so both branches carry it concurrently; I have not checked the git history to see which came first.

    In 1.x the gate lives in utils/output_schema_utils.py:

    def can_use_output_schema_with_tools(model: Union[str, BaseLlm]) -> bool:
      # LiteLLM handles tools + response_format compatibility per-provider:
      # - Providers with native support (OpenAI, Azure): both passed directly
      # - Providers without (Fireworks): auto-converted to json_tool_call +
      #   tool_choice enforcement
      # This is strictly more reliable than the SetModelResponseTool
      # prompt-based workaround.
      ...
      if LiteLlm is not None and isinstance(model, LiteLlm):
          return True

    Same hardcoded True for any LiteLlm instance, and the reasoning is equivalent to the comment now on LiteLlm.capabilities in 2.x, though the wording differs.

    Measured on 1.39.1, openrouter/google/gemini-3.1-flash-lite, one tool plus a BaseModel output_schema, one question:

    tool calls
    stock more than 25 (aborted the run)
    gate forced to False 2, correct output

    For anyone hitting this on 1.x there is no clean workaround, since 1.x has no capabilities property to override. The only seam is the module-level function, and it must be patched at the two import sites rather than at the definition, because both consumers import it by name:

    import google.adk.flows.llm_flows.basic as basic
    import google.adk.flows.llm_flows._output_schema_processor as osp
    
    patched = lambda model: False
    basic.can_use_output_schema_with_tools = patched
    osp.can_use_output_schema_with_tools = patched

    Verified: patching only these two gives 2 tool calls; patching only utils.output_schema_utils has no effect and the loop continues.

    That may be an argument for backporting whatever lands in #6955.

  3. added
    models[Component] This issue is related to model support
    on Aug 31, 2026
  4. added a commit that references this issue on Aug 31, 2026
    ced2d36
  5. sanketpatil06 commented on Sep 2, 2026

    @sanketpatil06

    Hi @yrangana, thanks for reporting this issue. PR #6955 has already been opened by a community member to address this.
    Could you please try it out and let us know if it resolves the issue on your end before we move forward with reviewing and merging it?

  6. added
    request clarification[Status] The maintainer need clarification or more information from the author
    on Sep 2, 2026
  7. yrangana commented on Sep 2, 2026

    @yrangana
    Author

    Yes, tested it and it fixes the issue. Details are on the PR: #6955 (comment)

    Short version: 2 calls per turn instead of 4-19, on openrouter/google/gemini-3.1-flash-lite.

    That is one model though, not every LiteLLM provider.

  8. removed
    request clarification[Status] The maintainer need clarification or more information from the author
    on Sep 4, 2026
  9. added a commit that references this issue on Sep 21, 2026
    e37af14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

models[Component] This issue is related to model support

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions