Dry-run et liste blanche avant chaque requête
Où s'accroche le contrôle
Un before_tool_callback est appelé par ADK juste avant l'exécution d'un outil, avec l'outil, ses arguments et le contexte. S'il renvoie None, l'outil s'exécute ; s'il renvoie un dictionnaire, ce dictionnaire remplace le résultat de l'outil, qui n'est pas exécuté.
flowchart LR
M["Gemini propose execute_sql(query)"] --> CB{"controle_cout"}
CB -->|"estimer : dry-run"| BQ["BigQuery"]
BQ --> V{"verifier"}
V -->|"None"| EX["execute_sql s'exécute"]
V -->|"raison du refus"| ER["status ERROR renvoyé au modèle"]
ER --> M
Trois fonctions courtes
estimer : le dry-run
@cache
def client_bq():
from google.cloud import bigquery
return bigquery.Client(project=PROJET)
def estimer(requete: str) -> dict:
"""Dry-run BigQuery : type d'instruction, tables lues, octets traités. Rien n'est facturé."""
from google.cloud import bigquery
job = client_bq().query(requete, job_config=bigquery.QueryJobConfig(
dry_run=True, use_query_cache=False, labels=LABELS))
return {"type": job.statement_type, "octets": job.total_bytes_processed or 0,
"tables": sorted(f"{t.project}.{t.dataset_id}.{t.table_id}"
for t in job.referenced_tables)}use_query_cache=False donne l'estimation sans cache : avec le cache, une requête déjà lancée serait estimée à 0 octet, ce qui fausserait le contrôle. @cache crée le client une seule fois, au premier besoin : les tests, qui remplacent estimer, n'ont jamais besoin d'identifiants.
verifier : la décision, en Python pur
DATASET = "bigquery-public-data.thelook_ecommerce"
TABLES_AUTORISEES = {f"{DATASET}.{t}" for t in
("orders", "order_items", "products", "distribution_centers")}
def verifier(estimation: dict) -> str | None:
if estimation["type"] != "SELECT":
return f"Seules les requêtes SELECT sont permises (reçu : {estimation['type']})."
interdites = [t for t in estimation["tables"] if t not in TABLES_AUTORISEES]
if interdites:
return (f"Tables non autorisées : {', '.join(interdites)}. Tables permises : " ...)
if estimation["octets"] > MAX_OCTETS:
return (f"Requête trop coûteuse : {estimation['octets'] / 1e6:.0f} Mo estimés, "
f"plafond {MAX_OCTETS / 1e6:.0f} Mo. Sélectionne moins de colonnes ou filtre.")
return NoneLa liste des tables vient de BigQuery (referenced_tables), pas d'une analyse du texte : une sous-requête, une vue ou un alias ne la trompent pas. users (e-mails et adresses, même fictifs) et events (385 Mo) sont hors liste blanche.
Le modèle écrit SELECT o.order_id FROM `bigquery-public-data.thelook_ecommerce.orders` o WHERE o.user_id IN (SELECT id FROM `bigquery-public-data.thelook_ecommerce.users` WHERE state = 'Texas'). Que fait verifier() ?
referenced_tables contient toutes les tables lues par la requête, sous-requêtes comprises. users n'étant pas autorisée, la requête est refusée avant toute exécution.
controle_cout : le callback
def controle_cout(tool, args: dict, tool_context) -> dict | None:
"""before_tool_callback : dry-run et vérifications AVANT toute exécution réelle."""
if tool.name == "get_table_info":
cible = f"{args.get('project_id')}.{args.get('dataset_id')}.{args.get('table_id')}"
if cible not in TABLES_AUTORISEES:
return {"status": "ERROR", "error_details": f"Table non autorisée : {cible}."}
return None
if tool.name != "execute_sql":
return None
try:
estimation = estimer(args.get("query", ""))
except Exception as e: # SQL invalide : le dry-run échoue, on renvoie l'erreur au modèle
return {"status": "ERROR", "error_details": f"Dry-run refusé : {e}"}
tool_context.state["estimations"] = tool_context.state.get("estimations", []) + [
{"sql": args.get("query"), **estimation}]
raison = verifier(estimation)
return {"status": "ERROR", "error_details": raison} if raison else None- Le format
{"status": "ERROR", "error_details": ...}est celui des outils BigQuery d'ADK : le modèle reçoit un refus qui ressemble à une erreur BigQuery normale. - Un SQL invalide fait échouer le dry-run : le message d'erreur de BigQuery (colonne inconnue, syntaxe…) part au modèle, qui corrige.
- Chaque estimation est journalisée dans l'état : c'est la trace d'audit des coûts.
| Situation | Qui réagit | Résultat |
|---|---|---|
DELETE, DROP, CREATE | sur le jeu public : le dry-run lui-même (« Access Denied ») ; sur vos tables : verifier (type ≠ SELECT), puis WriteMode.BLOCKED | refus |
table users ou events | verifier (liste blanche) | refus |
SELECT * sur une table trop grosse | verifier (octets), puis maximum_bytes_billed | refus, avec conseil |
| colonne inexistante | dry-run de BigQuery | erreur renvoyée, le modèle corrige |
Pourquoi le callback renvoie-t-il un dictionnaire d'erreur plutôt que de lever une exception ?
Renvoyer un résultat de remplacement est le mécanisme prévu par ADK pour court-circuiter un outil. Le modèle voit l'erreur comme un résultat d'outil et peut réagir, comme dans la trace ci-dessous.
Exercice : un budget par conversation
Un plafond par requête n'empêche pas 50 requêtes de 900 Mo. Ajoutez un budget par session de 3 Go : au-delà, toute nouvelle requête est refusée.
Voir la solution
Les estimations sont déjà journalisées dans tool_context.state["estimations"] ; il suffit de les additionner avant d'accepter la suivante.
BUDGET_SESSION = 3_000_000_000
def controle_cout(tool, args, tool_context):
...
deja = sum(e["octets"] for e in tool_context.state.get("estimations", []))
if deja + estimation["octets"] > BUDGET_SESSION:
return {"status": "ERROR",
"error_details": f"Budget de la conversation atteint ({deja / 1e9:.1f} Go)."}
tool_context.state["estimations"] = ...Pour un budget par utilisateur et par jour, utilisez une clé d'état préfixée user: avec un service de sessions persistant, ou mieux, les quotas personnalisés de BigQuery (par projet ou par utilisateur), appliqués côté Google Cloud.
Résultat obtenu
Trace réelle du 2026-10-15, avec un plafond abaissé à 10 Mo (MAX_OCTETS=10485760, le minimum accepté) pour montrer le refus : « Montre-moi toutes les colonnes des 5 premières lignes de order_items. »
APPEL execute_sql SELECT * FROM `bigquery-public-data.thelook_ecommerce.order_items` LIMIT 5
RETOUR {'status': 'ERROR', 'error_details': 'Requête trop coûteuse : 14 Mo estimés, plafond 10 Mo.
Sélectionne moins de colonnes ou filtre.'}
APPEL get_table_info {'dataset_id': 'thelook_ecommerce', 'table_id': 'order_items', ...}
APPEL execute_sql SELECT * FROM `bigquery-public-data.thelook_ecommerce.order_items` LIMIT 5
RETOUR {'status': 'ERROR', 'error_details': 'Requête trop coûteuse : 14 Mo estimés, plafond 10 Mo. [...]'}
FINAL Je ne peux pas récupérer toutes les colonnes des 5 premières lignes de la table
`order_items` directement, car la requête estimée dépasse la limite de coût autorisée
(14 Mo estimés, avec un plafond de 10 Mo). Il semble que même avec une clause `LIMIT`,
BigQuery estime le coût comme si toutes les données de la table devaient être lues.
ESTIM 13589426 octets ['bigquery-public-data.thelook_ecommerce.order_items']
ESTIM 13589426 octets ['bigquery-public-data.thelook_ecommerce.order_items']Aucune des deux requêtes n'a été exécutée ni facturée : seuls les dry-runs ont eu lieu. Le modèle a même retrouvé seul la leçon précédente : LIMIT ne réduit pas le volume lu.
Je sais faire
- Écrire un
before_tool_callbackqui court-circuite un outil en renvoyant un résultat - Obtenir par dry-run le type d'instruction, les tables lues et les octets estimés
- Appliquer une liste blanche de tables à partir de
referenced_tables - Journaliser les estimations dans l'état de session pour l'audit des coûts