●API — The deprecation of temperature, top_p, and top_k is a silent no-op rather than an error: requests still return 200 and the values are simply ignored●AUDIT — There is no runtime signal to catch it, so auditing means searching your codebase statically for the parameters, a gap developers are actively discussing●CHECK — Send the same prompt several times at temperature 0 and again at 1, then compare the spread of the outputs, and you can confirm for yourself that nothing changes●MODELS — Gemini 3.7 Flash scores 65.3% on DeepSWE v1.1 and 43.6% on FrontierCode 1.1 Main, with introductory pricing available through December 31, 2026●SEARCH — File Search now supports multimodal search through gemini-embedding-2, so images can be embedded and searched directly without a transcription step●DEPRECATION — gemini-robotics-er-1.6-preview shuts down on August 31, four days from now, with the ER 2 line in public preview since July 30 as the migration path●API — The deprecation of temperature, top_p, and top_k is a silent no-op rather than an error: requests still return 200 and the values are simply ignored●AUDIT — There is no runtime signal to catch it, so auditing means searching your codebase statically for the parameters, a gap developers are actively discussing●CHECK — Send the same prompt several times at temperature 0 and again at 1, then compare the spread of the outputs, and you can confirm for yourself that nothing changes●MODELS — Gemini 3.7 Flash scores 65.3% on DeepSWE v1.1 and 43.6% on FrontierCode 1.1 Main, with introductory pricing available through December 31, 2026●SEARCH — File Search now supports multimodal search through gemini-embedding-2, so images can be embedded and searched directly without a transcription step●DEPRECATION — gemini-robotics-er-1.6-preview shuts down on August 31, four days from now, with the ER 2 line in public preview since July 30 as the migration path
Record what you send before you try to measure whether temperature still works
Deprecated sampling parameters still return 200 and are silently ignored. Here is how a runtime recorder caught the call sites grep and AST both missed, kept the construction site attached to each config, and turned the ledger into a CI gate.
I read the one-line changelog entry, grepped my own code, and found three hits. temperature, top_p and top_k had been deprecated.
Three felt manageable. I nearly moved on.
What changed my mind was looking at the requests that were actually going out. There were five.
The awkward part of this kind of change is that it never surfaces as a failure. A request carrying a deprecated parameter still comes back 200. The value is accepted and then ignored. Code that believes it pinned temperature to zero keeps running, pinning nothing. No exception, no log line.
As an indie developer running a handful of small pipelines, I once catalogued how my toolchain reacts to a configuration key that does not exist. The answers fell into three tiers. The TypeScript compiler and ESLint fail outright. wrangler prints a warning and continues. npm and vitest say nothing at all. The tier that has always cost me the most hours is the third one.
Gemini's sampling deprecation sits squarely in that third tier.
Measuring "does it still work" is the wrong tool for an audit
The developer forums have been circling the same question: how do you inventory this, given there is no runtime signal?
The obvious answer is to send one prompt many times at temperature 0 and again at temperature 1, and check that the spread of outputs does not change. As a way of convincing yourself, that works fine.
As an audit tool, it does not. The classification jobs I run barely move even at temperature 1. When the answer space is closed, raising the value changes nothing regardless of deprecation. In that regime, "the spread did not change" is evidence for neither conclusion. There is a whole class of jobs where the test cannot decide anything.
And even a successful measurement tells you one thing about one job. That is not what a migration needs. A migration needs a complete list of where you are sending what.
So I reversed the order. Measuring effect went to the back of the queue. Recording what gets sent went to the front.
The three call sites grep found
To test this properly I set up five jobs that build their config in five different ways — roughly the spread you find in a codebase that has grown for a while.
Jobs d and e never appear, because the string does not exist in any Python file.
Widening the search to JSON produces five hits. But the two extra lines are the profile definitions themselves, and they tell you nothing about which job reads them. Three jobs sharing one profile still look like a single line. So does a profile nobody reads anymore. It is not usable as a count of call sites.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You will be able to prove on your own codebase why a setting that looks like 3 call sites under grep is actually being sent from 5
✦You will be able to close the audit gap around configs that arrive from external profiles or shared helpers, before a migration quietly leaves some behind
✦You will be able to show that a deprecation cleanup is finished as a CI pass rather than as something someone remembers doing
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Switching to AST raised the count, not the coverage
Assuming grep was simply too blunt, I counted again over the syntax tree.
# audit_static.py — static detection of deprecated keysimport ast, pathlib, sysDEPRECATED = {"temperature", "top_p", "top_k"}class Finder(ast.NodeVisitor): def __init__(self, path): self.path, self.hits = path, [] def _hit(self, node, how, key): self.hits.append((self.path, node.lineno, how, key)) def visit_Call(self, node): # f(temperature=...) for kw in node.keywords: if kw.arg in DEPRECATED: self._hit(node, "keyword-arg", kw.arg) self.generic_visit(node) def visit_Dict(self, node): # {"temperature": ...} for k in node.keys: if isinstance(k, ast.Constant) and k.value in DEPRECATED: self._hit(node, "dict-literal", k.value) self.generic_visit(node) def visit_Subscript(self, node): # cfg["temperature"] = ... s = node.slice if isinstance(s, ast.Constant) and s.value in DEPRECATED: self._hit(node, "subscript-assign", s.value) self.generic_visit(node)hits = []for p in sorted(pathlib.Path(sys.argv[1]).rglob("*.py")): f = Finder(str(p)) f.visit(ast.parse(p.read_text(encoding="utf-8"))) hits += f.hitsfor h in hits: print(f"{h[0]}:{h[1]} {h[2]:<16} {h[3]}")print(f"--- AST hits: {len(hits)} across {len({h[0] for h in hits})} files")
The run:
app/jobs/a_direct.py:4 keyword-arg temperature
app/jobs/a_direct.py:4 keyword-arg top_k
app/jobs/b_dict_literal.py:2 dict-literal temperature
app/jobs/c_assigned.py:3 subscript-assign temperature
--- AST hits: 4 across 3 files
Four hits instead of three. The extra one is top_k on the same line of a_direct.py — a second key counted separately. The number of jobs I could see stayed at three.
Making static analysis stronger does not reach values that arrive from outside the code. That is where I changed direction.
Catching it at runtime collapsed every origin onto one line
Looking at what actually goes out is the reliable move. I wrapped the SDK method and wrote down every config it received.
# recorder.py (first attempt)import functools, inspect, json, os, timeDEPRECATED = ("temperature", "top_p", "top_k")LEDGER = os.environ.get("GENAI_CONFIG_LEDGER", "config_ledger.jsonl")def _as_dict(config): if config is None: return {} if isinstance(config, dict): return dict(config) if hasattr(config, "to_dict"): return config.to_dict() return {k: v for k, v in vars(config).items() if not k.startswith("_")}def record_configs(models): original = models.generate_content @functools.wraps(original) def wrapped(*args, **kwargs): cfg = _as_dict(kwargs.get("config")) entry = { "ts": time.strftime("%Y-%m-%dT%H:%M:%S"), "model": kwargs.get("model"), "origin": f"{inspect.stack()[1].filename}:{inspect.stack()[1].lineno}", "deprecated": [k for k in DEPRECATED if k in cfg], } with open(LEDGER, "a", encoding="utf-8") as f: f.write(json.dumps(entry, ensure_ascii=False) + "\n") return original(*args, **kwargs) models.generate_content = wrapped return models
Running all five jobs gave me five records, exactly as intended. Then I looked at the origins:
run_batch.py:13 temperature,top_k
run_batch.py:13 temperature
run_batch.py:13 temperature
run_batch.py:13 temperature,top_p
run_batch.py:13 temperature
All the same line.
In hindsight it could not have gone any other way. The thing calling generate_content is the dispatch loop, not the code that decided the setting. Each job's build() has already returned and left the stack. Walking up from the call site never leads back to where the config came from.
I had completeness and no idea what to fix. The gap was closed and the ledger was still useless.
Attaching provenance to the config itself
If the information is gone by call time, it has to be captured at construction time.
So I made a type that behaves like a dict and remembers where it was built. __slots__ keeps the provenance off the dictionary keys entirely.
# tracked.pyimport inspect, os_SKIP = {os.path.abspath(__file__)}def register_factory(path: str) -> None: """Exclude a shared config-building helper from origin resolution.""" _SKIP.add(os.path.abspath(path))class TrackedConfig(dict): __slots__ = ("origin", "chain") def __init__(self, mapping=None, /, **kw): super().__init__(mapping or {}, **kw) self.chain = _origin_chain(depth=2) self.origin = self.chain[0] if self.chain else "unknown" def plain(self) -> dict: """Strip back to a plain dict just before sending.""" return dict(self)def _origin_chain(depth: int = 2): out = [] for fr in inspect.stack()[1:]: if os.path.abspath(fr.filename) in _SKIP: continue out.append(f"{os.path.relpath(fr.filename)}:{fr.lineno}") if len(out) >= depth: break return out
I changed each build() to return a TrackedConfig and ran the batch again. Construction sites now appear — except that d and e came out like this:
app/common/config.py:9 temperature,top_p
app/common/config.py:12 temperature
The shared helper. Those two lines really are where the profile gets read, but the decision that needs revisiting belongs to the job that named the profile. If ten jobs call load_profile, ten records flatten onto one line. The same failure as the first attempt, one level further in.
Skipping the helper during origin resolution
That is what register_factory is for. Register the file that builds configs, and origin resolution steps over it.
# app/common/config.pyimport json, osfrom tracked import TrackedConfig, register_factoryBASE = {"max_output_tokens": 1024}def load_profile(name: str) -> dict: path = os.path.join(os.path.dirname(__file__), "profiles", f"{name}.json") with open(path, encoding="utf-8") as f: return TrackedConfig(json.load(f))def merged(profile_name: str, **override) -> dict: return TrackedConfig({**BASE, **load_profile(profile_name), **override})register_factory(__file__) # never report this file as an origin
Same batch, new result:
app/jobs/a_direct.py:4 <- run_batch2.py:12 temperature,top_k
app/jobs/b_dict_literal.py:4 <- run_batch2.py:12 temperature
app/jobs/c_assigned.py:4 <- run_batch2.py:12 temperature
app/jobs/d_profile.py:4 <- run_batch2.py:12 temperature,top_p
app/jobs/e_merged.py:4 <- run_batch2.py:12 temperature
All five resolve to the job that asked for them. Left is the construction site, right is its caller. Keeping two frames leaves you a handle when the sharing goes one layer deeper.
Side by side:
Approach
Call sites found
Construction site
grep (*.py)
3 of 5
yes
grep (including JSON)
5 lines
no — which job reads it is unknown
AST static analysis
3 of 5 (4 hits)
yes
Runtime recorder (v1)
5 of 5
no — every record is the dispatch line
TrackedConfig + helper skip
5 of 5
yes
Turning the ledger into a CI gate
A list you have to remember to read is a list you stop reading. I turned "the migration is done" into a pass or fail.
# ci_gate.pyimport json, sys, collectionsrows = [json.loads(l) for l in open(sys.argv[1], encoding="utf-8")]untracked = [r for r in rows if r["origin"] == "untracked"]by_origin = collections.Counter(r["origin"] for r in rows if r["deprecated"])print(f"calls recorded: {len(rows)} / untracked calls: {len(untracked)}")for origin, n in sorted(by_origin.items()): keys = sorted({k for r in rows if r["origin"] == origin for k in r["deprecated"]}) print(f" {origin:<30} {n:>3}x {', '.join(keys)}")if untracked: print("FAIL: calls bypassing TrackedConfig — the inventory is incomplete") sys.exit(1)if by_origin: print(f"FAIL: {len(by_origin)} sites still send deprecated keys") sys.exit(1)print("OK: no deprecated keys sent")
Before the migration it lists five sites and exits 1. After stripping the keys from both the profiles and the jobs, it exits 0. Both expected.
The case that earned its keep was the third one. I slipped in a single plain dict after the fact:
calls recorded: 6 / untracked calls: 1
untracked 1x temperature
FAIL: calls bypassing TrackedConfig — the inventory is incomplete
It shows up as untracked. Failing on "this call never went through the path" is a stronger audit than failing on "this call carried a deprecated key." It closes the door on new code drifting out of coverage without anyone noticing.
Early in a migration the first check looks like enough. The second one starts earning its place once you have finished stripping the keys, because that is exactly when new code arrives. I would put the coverage check in first — do it the other way around and the only code outside your monitoring is the code written after the cleanup.
Because plain() converts back before sending, none of the tracking data reaches the API. On my machine the outgoing config is a plain dict carrying only the two original keys. Call sites that use the typed types.GenerateContentConfig cannot subclass a dict, so those go through a thin factory function that writes to the same ledger.
What this does not show you
Worth being direct about the limits.
What gets recorded is the value you sent, not whether it did anything. An empty ledger means you have stopped sending deprecated keys. It does not mean your outputs land where you want them. That belongs to regression testing, which I keep separate — see prompt regression testing with Pytest.
The other limit: runtime records only fill in for paths that actually run. A job that fires once a month stays invisible until that month arrives. So I did not throw the static checker away. Static analysis clears the sites whose shape is known; the runtime ledger fills in the ones whose shape is not. They cover different ground, and at the scale I work at as an indie developer, dropping either one has always left a hole somewhere. For pairing this with model-ID deadlines, see stopping model deprecations early in CI.
On the sampling deprecation itself, the part that actually broke first was the diversity side rather than the determinism side — that account is in what temperature deprecation broke first.
Start with your busiest job
One job is enough. Take whichever one calls the API most often, route its build() through TrackedConfig, and read a single day of the ledger. That alone tells you whether the sites you had in mind match the sites that are really sending.
I was carrying three in my head. There were five. Both of the ones I had missed were sitting where I had shared the code and stopped being able to see it.
Share
Thank You for Reading
Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.