I set up Claude Code to hand research and review jobs to Gemini. Over two days of building I hit a retired model, overloaded endpoints, an exhausted quota, an empty reply from a thinking model, and an agent that wanted to print my API key. None of these is exotic. Anyone wiring a second model into a coding agent will meet them, so here's each failure, how to recognise it from the API response, and what fixed it.
The snippets are trimmed from the broker I ended up writing (Python, httpx, Gemini REST generateContent).
Symptom: a model ID that worked in every tutorial returned HTTP 404, saying the model is no longer available to new users.
Why: model IDs get retired, and "retired for new users" means a fresh API key can't call a model that older keys still can. A hard-coded ID from a blog post (or from your own memory) is a time bomb.
Fix: ask the API which models your key can actually call, and treat 404 as "fail fast with the API's own message", never "retry".
def _classify(status: int, message: str) -> str:
m = message.lower()
if status == 429 and ("quota" in m or "billing" in m):
return "quota"
if status in RETRYABLE: # {429, 500, 502, 503, 504}
return "overloaded"
if status == 404:
return "not_found" # retired or misspelt model: don't retry
return "bad_request"
Symptom: 503s on two Flash models at the same time, with the agent sitting there for minutes.
Fix: jittered exponential backoff that honours Retry-After, capped, then a fallback chain of models, with a small retry budget per model so a saturated chain fails in seconds rather than minutes.
def _delay(self, attempt: int, r: httpx.Response | None) -> float:
delay = min(self.cfg.max_delay_s, self.cfg.base_delay_s * 2 ** (attempt - 1))
ra = r.headers.get("retry-after") if r is not None else None
if ra and ra.isdigit():
delay = min(self.cfg.max_delay_s, float(ra))
return delay + random.uniform(0, delay * 0.25)
Symptom: 429 RESOURCE_EXHAUSTED. My first retry loop lumped it in with 503s, so it kept retrying, and every retry spent more of a daily allowance that was already gone.
Why: a 429 for "too many requests right now" and a 429 for "you've used today's quota" look the same at the status-code level. The message tells them apart. On the free tier I measured roughly 20 requests per day per model, so this happens fast.
Fix: classify quota separately (see _classify above) and trip a process-wide circuit breaker. Every later call fails immediately, without touching the API, until a cooldown passes.
class QuotaBreaker:
"""Once the project's quota is exhausted, stop calling the API for a while."""
def __init__(self, cooldown_s: float):
self.cooldown_s, self._tripped_at = cooldown_s, None
self._lock = threading.Lock()
def trip(self) -> None:
with self._lock:
self._tripped_at = time.monotonic()
def is_open(self) -> bool:
with self._lock:
return self._tripped_at is not None and time.monotonic() - self._tripped_at < self.cooldown_s
Symptom: HTTP 200, no error, and text == "".
Why: thinking tokens come out of the same output budget as the answer. In my logs, a one-word reply cost 2 answer tokens and 68 thinking tokens. Set a small maxOutputTokens and the thinking can use all of it, leaving nothing for the answer. The response tells you, but only in finishReason: MAX_TOKENS.
Fix: give thinking models real output headroom, drop the thought parts, and turn an empty answer into an error that names the cause instead of returning a blank string:
parts = (c.get("content") or {}).get("parts") or []
res.text = "".join(p.get("text", "") for p in parts if not p.get("thought"))
res.ok = bool(res.text)
if not res.ok:
hint = " - raise max_output_tokens (thinking used the budget)" if c.get("finishReason") == "MAX_TOKENS" else ""
res.error = f"empty response (finishReason={c.get('finishReason')}){hint}"
Symptom: to confirm the key was set, Claude Code proposed echo $GEMINI_API_KEY. That puts the key into the transcript, the logs and anything you later share.
Fix: a PreToolUse hook that blocks the common leak paths (echoing or printing *_KEY/*_TOKEN variables, printenv/ env dumps, PowerShell $env:, reading .env files, writing live-looking keys into code) and suggests a presence check instead ([ -n "$VAR" ] && echo set).
One gotcha: on Windows some shells pipe the hook's JSON input with a UTF-8 BOM. A plain json.loads then raises, and a hook that fails open on bad input lets everything through. Decode with utf-8-sig:
event = json.loads(sys.stdin.buffer.read().decode("utf-8-sig"))
It's pattern-based, so it's a seatbelt, not a sandbox. It catches the common ways an agent leaks a key, not every possible one.
Claude stays the primary agent. It delegates research and second-opinion reviews through the broker, then checks what comes back before acting on it. The broker owns retries, fallback, the quota breaker and usage logging; the hook sits between the agent and your environment variables.
The hook is free and MIT-licensed on its own: claude-secret-guard. The full kit (broker, a Claude Code subagent, /research and /second-opinion commands, the hook and 57 offline tests) is Switchboard, a one-time US$29 download. You bring your own Gemini API key, so Google's quotas and pricing apply.
What failure modes have you hit when wiring two models together? I'd especially like to hear about ones that look like success, like the empty-string case.
This post was drafted with help from an AI assistant (Claude). The errors, numbers and code come from Switchboard's own source and build logs.