Function Calling Gotchas

Three breaking changes in Gemini 3.5 Flash that affect tool-use / function-calling workflows. One causes a 400, two are silent.

๐Ÿ”ด
breaks with 400

FunctionResponse: id + name required

Missing either field โ†’ HTTP 400. Check every tool call handler.

๐Ÿ”ด
breaks with 400

thinking_level + thinking_budget conflict

Using both in ThinkingConfig โ†’ HTTP 400. Remove thinking_budget.

๐ŸŸก
silent cost spike

Multi-turn token blowup

Thought preservation is now ON. Thinking tokens accumulate silently in chat history.

1

FunctionResponse: id + name required

Both id and name fields are required in every FunctionResponse. Missing either one returns HTTP 400. This applies to Python SDK, TypeScript SDK, and REST API.

โŒ Broken code (gemini-3-flash-preview pattern)
# โŒ BROKEN in gemini-3.5-flash โ€” missing id and name
tool_response_part = {
    "functionResponse": {
        "response": {"result": "72ยฐF and sunny"}  # no id, no name
    }
}
# Result: HTTP 400 Bad Request
โœ“ Fixed code
# โœ“ CORRECT in gemini-3.5-flash
from google.generativeai import types

# After receiving a function call from the model:
# function_call.id = "call_abc123"
# function_call.name = "get_weather"

tool_response_part = types.Part(
    function_response=types.FunctionResponse(
        id=function_call.id,          # โ† match the call's id exactly
        name=function_call.name,      # โ† match the function name exactly
        response={"result": "72ยฐF and sunny"}
    )
)

# Then send back:
response = model.generate_content([user_message, model_response, tool_response_part])
TypeScript SDK
// TypeScript SDK โ€” FunctionResponse with id + name required
const functionResponse = {
  functionResponse: {
    id: functionCall.id,        // โ† required
    name: functionCall.name,    // โ† required
    response: {
      result: "72ยฐF and sunny"
    }
  }
};

const result = await model.generateContent([
  userMessage,
  modelResponse,
  { role: "tool", parts: [functionResponse] }
]);
2

thinking_level + thinking_budget conflict โ†’ 400

thinking_budget is deprecated and cannot be combined with thinking_level in the same ThinkingConfig. Using both throws HTTP 400. Remove thinking_budget entirely.

# โŒ BREAKS with 400 โ€” do not mix thinking_level + thinking_budget
generation_config = GenerationConfig(
    thinking_config=ThinkingConfig(
        thinking_level="high",    # โ† these two
        thinking_budget=8192,     # โ† cannot coexist โ†’ HTTP 400
    )
)

# โœ“ CORRECT โ€” pick one
generation_config = GenerationConfig(
    thinking_config=ThinkingConfig(
        thinking_level="high",    # use thinking_level only
        # no thinking_budget
    )
)
3

Multi-turn token blowup from thought preservation

Thought preservation is now ON by default. Every turn in a chat session appends the model's thinking tokens to the conversation history. At thinking_level="high", that's ~8K tokens per turn added to context โ€” leading to exponential cost growth and potential context overflow in long agentic loops.

# โš  Token blowup โ€” multi-turn with thought preservation ON (default)
chat = model.start_chat(history=[])

# Turn 1: ~8K thinking tokens (high) + output
r1 = chat.send_message("Analyze this large codebase...")
# chat.history now includes 8K thinking tokens

# Turn 2: model sees previous 8K thinking + output + new prompt
r2 = chat.send_message("Now write tests for the main module...")
# chat.history grows to ~16K thinking + 2 outputs

# By turn 10: ~80K thinking tokens in context
# Cost spikes, context window exhausted

# โœ“ FIX A: disable thought preservation for chat
model = genai.GenerativeModel(
    "gemini-3.5-flash",
    generation_config=GenerationConfig(
        thinking_config=ThinkingConfig(
            thinking_level="high",
            thoughts_in_content=False,   # thinking tokens NOT added to history
        )
    )
)

# โœ“ FIX B: trim history after each turn
MAX_TURNS = 5
if len(chat.history) > MAX_TURNS * 2:
    chat.history = chat.history[-(MAX_TURNS * 2):]
4

Parallel function calls โ€” id matching

When the model returns multiple function calls in one response, each has a unique id. You must match the correct id back in each FunctionResponse โ€” especially important when executing tools in parallel.

# Parallel function calling โ€” id matching matters more now
# The model can return multiple function calls in one response

response = model.generate_content(prompt_with_tools)

# Each function_call has a unique id
function_calls = response.candidates[0].content.parts
# [FunctionCall(id="call_001", name="search"), FunctionCall(id="call_002", name="fetch")]

# Execute them (optionally in parallel)
import asyncio
results = await asyncio.gather(
    execute_tool("search", fc1.args),
    execute_tool("fetch", fc2.args),
)

# Return responses โ€” MUST match ids back
tool_parts = [
    types.Part(function_response=types.FunctionResponse(
        id=function_calls[0].function_call.id,   # "call_001"
        name=function_calls[0].function_call.name, # "search"
        response={"result": results[0]},
    )),
    types.Part(function_response=types.FunctionResponse(
        id=function_calls[1].function_call.id,   # "call_002"
        name=function_calls[1].function_call.name, # "fetch"
        response={"result": results[1]},
    )),
]