refract-mcp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ast_extractor.py ADDED
@@ -0,0 +1,400 @@
1
+ """
2
+ ast_extractor — Token Optimizer (solution consolidée).
3
+
4
+ Deux niveaux :
5
+ * extract(source) -> contrat plat {imports, fonctions} (contrat P1)
6
+ * compress(source) -> bundle compressé S5 (vision en couches + deps)
7
+ * count_tokens(text) -> tokens tiktoken (si dispo)
8
+
9
+ CLI : python ast_extractor.py <fichier.py> -> JSON du contrat plat sur stdout.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import ast
15
+ import json
16
+ import sys
17
+
18
+
19
+ # =========================================================================== #
20
+ # Contrat plat (testé par test_extractor.py)
21
+ # =========================================================================== #
22
+ def _module_function_names(tree: ast.AST) -> set[str]:
23
+ return {
24
+ n.name
25
+ for n in ast.walk(tree)
26
+ if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
27
+ }
28
+
29
+
30
+ def _extract_imports(tree: ast.AST) -> list[str]:
31
+ """Noms de modules top-level, dédupliqués, sans partie pointée ni classe."""
32
+ out: list[str] = []
33
+ seen: set[str] = set()
34
+
35
+ def add(name: str):
36
+ if name and name not in seen:
37
+ seen.add(name)
38
+ out.append(name)
39
+
40
+ for node in ast.walk(tree):
41
+ if isinstance(node, ast.Import):
42
+ for alias in node.names:
43
+ add(alias.name.split(".")[0])
44
+ elif isinstance(node, ast.ImportFrom):
45
+ # relatif ('from . import x') -> module None : on ignore, pas de crash
46
+ if node.module:
47
+ add(node.module.split(".")[0])
48
+ return out
49
+
50
+
51
+ def _extract_function(node, func_names: set[str]) -> dict:
52
+ params = [a.arg for a in node.args.args]
53
+ appels: list[str] = []
54
+ seen: set[str] = set()
55
+ for n in ast.walk(node):
56
+ if isinstance(n, ast.Call) and isinstance(n.func, ast.Name):
57
+ nom = n.func.id
58
+ if nom in func_names and nom not in seen: # interne uniquement
59
+ seen.add(nom)
60
+ appels.append(nom)
61
+ return {"nom": node.name, "params": params, "appels": appels}
62
+
63
+
64
+ def extract(source: str) -> dict:
65
+ """Contrat plat : {'imports': [...], 'fonctions': [{nom, params, appels}]}."""
66
+ tree = ast.parse(source)
67
+ func_names = _module_function_names(tree)
68
+ fonctions = [
69
+ _extract_function(n, func_names)
70
+ for n in ast.walk(tree)
71
+ if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
72
+ ]
73
+ return {"imports": _extract_imports(tree), "fonctions": fonctions}
74
+
75
+
76
+ def _appels_complets(node) -> list[str]:
77
+ """Tous les appels (nom complet pointé), dédupliqués, dans l'ordre.
78
+ Corrige le contrat plat dont `appels` (internes only) était vide sur du
79
+ vrai code. Format conforme à summary.md : ['bcrypt.checkpw', 'jwt.encode'...]."""
80
+ out, seen = [], set()
81
+ for n in ast.walk(node):
82
+ if isinstance(n, ast.Call):
83
+ try:
84
+ nom = ast.unparse(n.func)
85
+ except Exception:
86
+ continue
87
+ if nom and nom not in seen:
88
+ seen.add(nom)
89
+ out.append(nom)
90
+ return out
91
+
92
+
93
+ def extract_json(source: str) -> dict:
94
+ """Sortie JSON CORRIGÉE : appels = tous les appels (contrat summary.md)."""
95
+ tree = ast.parse(source)
96
+ fonctions = []
97
+ for n in ast.walk(tree):
98
+ if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)):
99
+ f = {"nom": n.name, "params": [a.arg for a in n.args.args],
100
+ "appels": _appels_complets(n)}
101
+ if (doc := ast.get_docstring(n)):
102
+ f["doc"] = doc.strip().splitlines()[0]
103
+ fonctions.append(f)
104
+ return {"imports": _extract_imports(tree), "fonctions": fonctions}
105
+
106
+
107
+ # =========================================================================== #
108
+ # Moteur S5 : vision en couches + dépendances
109
+ # =========================================================================== #
110
+ class IndexModule:
111
+ """Index des définitions module-level (le 'vocabulaire')."""
112
+
113
+ def __init__(self, tree: ast.AST):
114
+ self.data: set[str] = set()
115
+ self.classes: set[str] = set()
116
+ self.fonctions: set[str] = set()
117
+ self.imports: set[str] = set()
118
+ for node in tree.body:
119
+ if isinstance(node, ast.Assign):
120
+ for t in node.targets:
121
+ if isinstance(t, ast.Name):
122
+ self.data.add(t.id)
123
+ elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
124
+ self.data.add(node.target.id)
125
+ elif isinstance(node, ast.ClassDef):
126
+ self.classes.add(node.name)
127
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
128
+ self.fonctions.add(node.name)
129
+ elif isinstance(node, ast.Import):
130
+ for a in node.names:
131
+ self.imports.add((a.asname or a.name).split(".")[0])
132
+ elif isinstance(node, ast.ImportFrom):
133
+ for a in node.names:
134
+ self.imports.add(a.asname or a.name)
135
+
136
+
137
+ class ClassIndex:
138
+ """Vocabulaire d'une classe : attributs (self.x + niveau classe) + méthodes.
139
+ Joue pour `self.x` le rôle qu'IndexModule joue pour les noms module-level."""
140
+
141
+ def __init__(self, node: ast.ClassDef):
142
+ self.methods: set[str] = set()
143
+ self.attrs: set[str] = set() # tous les self.X = ... + attrs de classe
144
+ self.attrs_init: set[str] = set() # ceux définis dans __init__/__new__
145
+ for m in node.body:
146
+ if isinstance(m, (ast.FunctionDef, ast.AsyncFunctionDef)):
147
+ self.methods.add(m.name)
148
+ elif isinstance(m, ast.Assign):
149
+ for t in m.targets:
150
+ if isinstance(t, ast.Name):
151
+ self.attrs.add(t.id)
152
+ elif isinstance(m, ast.AnnAssign) and isinstance(m.target, ast.Name):
153
+ self.attrs.add(m.target.id)
154
+ for m in node.body: # attributs d'instance (self.X = ...)
155
+ if isinstance(m, (ast.FunctionDef, ast.AsyncFunctionDef)):
156
+ cible = self.attrs_init if m.name in ("__init__", "__new__") else None
157
+ for n in ast.walk(m):
158
+ if (isinstance(n, ast.Attribute) and isinstance(n.ctx, ast.Store)
159
+ and isinstance(n.value, ast.Name) and n.value.id == "self"):
160
+ self.attrs.add(n.attr)
161
+ if cible is not None:
162
+ cible.add(n.attr)
163
+
164
+
165
+ # noms qui ne sont PAS de vraies dépendances de données (bruit)
166
+ _TYPEVAR_NOISE = {"T", "R", "V", "K", "S", "U", "_", "P"}
167
+
168
+
169
+ def dependances(node, idx: IndexModule, cls: "ClassIndex | None" = None) -> dict:
170
+ """Dépendances réelles : on lit les Name LUS (pas seulement les Call).
171
+ Si `cls` est fourni, on résout aussi `self.attr` / `self.methode()` contre le
172
+ vocabulaire de la classe (sinon ces accès sont ignorés, comportement S5 pur)."""
173
+ locales: set[str] = {a.arg for a in node.args.args}
174
+ locales |= {a.arg for a in node.args.kwonlyargs}
175
+ if node.args.vararg:
176
+ locales.add(node.args.vararg.arg)
177
+ if node.args.kwarg:
178
+ locales.add(node.args.kwarg.arg)
179
+ for n in ast.walk(node):
180
+ if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Store):
181
+ locales.add(n.id)
182
+ elif isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) and n is not node:
183
+ locales.add(n.name)
184
+
185
+ data, interne, externe, typ = set(), set(), set(), set()
186
+ for n in ast.walk(node):
187
+ if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Load):
188
+ nom = n.id
189
+ if nom in locales or nom in _TYPEVAR_NOISE:
190
+ continue
191
+ if nom in idx.data:
192
+ data.add(nom)
193
+ elif nom in idx.fonctions:
194
+ interne.add(nom)
195
+ elif nom in idx.classes:
196
+ typ.add(nom)
197
+ elif nom in idx.imports:
198
+ externe.add(nom)
199
+ elif isinstance(n, ast.Attribute) and isinstance(n.value, ast.Name):
200
+ racine = n.value.id
201
+ if racine in idx.imports and racine not in locales:
202
+ externe.add(racine)
203
+ elif (cls is not None and racine == "self"
204
+ and isinstance(n.ctx, ast.Load)):
205
+ if n.attr in cls.methods:
206
+ interne.add("self." + n.attr) # méthode soeur -> ORCH
207
+ elif n.attr in cls.attrs:
208
+ data.add("self." + n.attr) # vocabulaire de classe -> TOOL
209
+
210
+ return {"data": sorted(data), "type": sorted(typ),
211
+ "interne": sorted(interne), "externe": sorted(externe)}
212
+
213
+
214
+ def role(deps: dict) -> str:
215
+ n_int = len(deps["interne"])
216
+ if n_int >= 2:
217
+ return "ORCH"
218
+ if deps["data"] or deps["type"] or deps["externe"]:
219
+ return "TOOL"
220
+ return "PURE"
221
+
222
+
223
+ # --------------------------------------------------------------------------- #
224
+ # Rendu du bundle compressé S5 (texte)
225
+ # --------------------------------------------------------------------------- #
226
+ def _sig(node) -> str:
227
+ params = []
228
+ a = node.args
229
+ defaults = [None] * (len(a.args) - len(a.defaults)) + list(a.defaults)
230
+ for arg, d in zip(a.args, defaults):
231
+ s = arg.arg + (f": {ast.unparse(arg.annotation)}" if arg.annotation else "")
232
+ if d is not None:
233
+ s += f" = {ast.unparse(d)}"
234
+ params.append(s)
235
+ if a.vararg:
236
+ params.append("*" + a.vararg.arg)
237
+ for arg, d in zip(a.kwonlyargs, a.kw_defaults):
238
+ s = arg.arg + (f" = {ast.unparse(d)}" if d is not None else "")
239
+ params.append(s)
240
+ if a.kwarg:
241
+ params.append("**" + a.kwarg.arg)
242
+ pre = "async " if isinstance(node, ast.AsyncFunctionDef) else ""
243
+ ret = f" -> {ast.unparse(node.returns)}" if node.returns else ""
244
+ return f"{pre}def {node.name}({', '.join(params)}){ret}:"
245
+
246
+
247
+ def _doc(node, indent=" "):
248
+ d = ast.get_docstring(node)
249
+ return f'{indent}"""{d.strip().splitlines()[0]}"""' if d else None
250
+
251
+
252
+ def _own_nodes(node):
253
+ """Descend dans le corps de `node` SANS entrer dans les fonctions/lambdas
254
+ imbriquées — pour attribuer un raise/yield/warn à la bonne fonction."""
255
+ for child in ast.iter_child_nodes(node):
256
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
257
+ continue
258
+ yield child
259
+ yield from _own_nodes(child)
260
+
261
+
262
+ def _contract_tags(node) -> dict:
263
+ """Contrats invisibles dans la signature, extraits SANS garder le corps :
264
+ raises (types levés), yields (générateur), warns (avertissements émis).
265
+ ~0 token, rebouche le signal le plus dangereux des fonctions strippées."""
266
+ raises, warns, is_gen = [], [], False
267
+ for n in _own_nodes(node):
268
+ if isinstance(n, ast.Raise) and n.exc:
269
+ exc = n.exc.func if isinstance(n.exc, ast.Call) else n.exc
270
+ try:
271
+ raises.append(ast.unparse(exc))
272
+ except Exception:
273
+ pass
274
+ elif isinstance(n, (ast.Yield, ast.YieldFrom)):
275
+ is_gen = True
276
+ elif isinstance(n, ast.Call):
277
+ try:
278
+ nom = ast.unparse(n.func)
279
+ except Exception:
280
+ nom = ""
281
+ if "warn" in nom.lower():
282
+ warns.append(nom)
283
+ tags = {}
284
+ if raises:
285
+ tags["raises"] = list(dict.fromkeys(raises))
286
+ if is_gen:
287
+ tags["yields"] = True
288
+ if warns:
289
+ tags["warns"] = list(dict.fromkeys(warns))
290
+ return tags
291
+
292
+
293
+ def _render_func(node, idx, indent="", tags=False, cls=None):
294
+ deps = dependances(node, idx, cls)
295
+ r = role(deps)
296
+ if r == "PURE": # corps gardé
297
+ return ["\n".join(indent + l for l in ast.unparse(node).splitlines())]
298
+ lignes = [indent + _sig(node)]
299
+ doc = _doc(node, indent + " ")
300
+ if doc:
301
+ lignes.append(doc)
302
+ besoin = {k: v for k, v in deps.items() if v}
303
+ if tags:
304
+ besoin.update(_contract_tags(node)) # raises/yields/warns
305
+ if besoin:
306
+ lignes.append(indent + " # needs: " + json.dumps(besoin))
307
+ return lignes
308
+
309
+
310
+ def _render_class(node, idx, tags=False, resolve_self=False):
311
+ bases = ", ".join(ast.unparse(b) for b in node.bases)
312
+ out = [f"class {node.name}({bases}):"]
313
+ d = _doc(node)
314
+ if d:
315
+ out.append(d)
316
+ cls = ClassIndex(node) if resolve_self else None
317
+
318
+ if resolve_self:
319
+ # vocabulaire de classe gardé une fois (attrs niveau classe)
320
+ for m in node.body:
321
+ if isinstance(m, (ast.Assign, ast.AnnAssign)):
322
+ out.append(" " + ast.unparse(m))
323
+ # attributs d'instance définis hors __init__ : déclarés en manifeste
324
+ # (sinon une dep self.x serait nommée sans être ancrée dans le bundle)
325
+ hors_init = sorted(cls.attrs - cls.attrs_init)
326
+ if hors_init:
327
+ out.append(" # attrs: " + ", ".join(hors_init))
328
+
329
+ for m in node.body:
330
+ if not isinstance(m, (ast.FunctionDef, ast.AsyncFunctionDef)):
331
+ continue
332
+ if resolve_self and m.name in ("__init__", "__new__"):
333
+ # le corps définit le vocabulaire d'instance -> gardé verbatim
334
+ out.append("\n".join(" " + l for l in ast.unparse(m).splitlines()))
335
+ else:
336
+ out += _render_func(m, idx, indent=" ", tags=tags, cls=cls)
337
+ return out
338
+
339
+
340
+ def compress(source: str, tags: bool = False, resolve_self: bool = False) -> str:
341
+ """Bundle S5 : vocabulaire gardé + fonctions compressées par rôle.
342
+
343
+ tags=False (défaut) : S5 pur (corps strippé = signature + deps).
344
+ tags=True : ajoute les contrats raises/yields/warns au # needs,
345
+ sans garder le corps (coût ~0 token, signal récupéré).
346
+ resolve_self=False (déf.): self.attr/self.methode() ignorés (S5 pur).
347
+ resolve_self=True : résout self.x contre le vocabulaire de classe et
348
+ strippe les méthodes OO. Signal préservé à 100% (attrs
349
+ + __init__ gardés), MAIS coût net NÉGATIF en compression
350
+ (-6 pts mesurés sur 6 repos, cf. bench_self.py) : les
351
+ corps de méthodes OO sont trop courts pour que
352
+ 'signature + needs + vocabulaire' soit plus compact.
353
+ Garder OFF pour compresser ; utile pour la résolution
354
+ de clôture self.x en contexte ciblé (expand)."""
355
+ tree = ast.parse(source)
356
+ idx = IndexModule(tree)
357
+ out: list[str] = []
358
+
359
+ for node in tree.body:
360
+ if isinstance(node, (ast.Import, ast.ImportFrom, ast.Assign, ast.AnnAssign)):
361
+ out.append(ast.unparse(node)) # imports + vocabulaire DATA
362
+ elif isinstance(node, ast.ClassDef):
363
+ out += _render_class(node, idx, tags=tags, resolve_self=resolve_self)
364
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
365
+ out += _render_func(node, idx, tags=tags)
366
+
367
+ return "\n".join(out)
368
+
369
+
370
+ # =========================================================================== #
371
+ # Tokens
372
+ # =========================================================================== #
373
+ def count_tokens(text: str) -> int:
374
+ import tiktoken
375
+ return len(tiktoken.get_encoding("cl100k_base").encode(text))
376
+
377
+
378
+ # =========================================================================== #
379
+ # CLI
380
+ # =========================================================================== #
381
+ def main(argv):
382
+ if len(argv) != 2:
383
+ print("Usage: python ast_extractor.py <fichier.py>", file=sys.stderr)
384
+ return 1
385
+ try:
386
+ with open(argv[1], "r", encoding="utf-8") as f:
387
+ source = f.read()
388
+ result = extract(source)
389
+ except SyntaxError as e:
390
+ print(f"Erreur de syntaxe : {e}", file=sys.stderr)
391
+ return 2
392
+ except OSError as e:
393
+ print(f"Erreur fichier : {e}", file=sys.stderr)
394
+ return 3
395
+ print(json.dumps(result, indent=2, ensure_ascii=False))
396
+ return 0
397
+
398
+
399
+ if __name__ == "__main__":
400
+ sys.exit(main(sys.argv))
cache_injector.py ADDED
@@ -0,0 +1,135 @@
1
+ """
2
+ cache_injector — Prompt caching Anthropic pour les tools MCP compressés par Refract.
3
+
4
+ Le prompt caching Anthropic réduit le coût des tokens répétitifs de 3,00 $/M
5
+ à 0,30 $/M (×10 moins cher) pour les cache hits.
6
+
7
+ Les schémas MCP d'une session Refract ne changent pas d'une requête à l'autre
8
+ → candidats parfaits au caching.
9
+
10
+ Règle Anthropic : on pose cache_control sur le DERNIER élément du bloc
11
+ qu'on veut cacher. Tout ce qui précède cet élément est inclus dans le cache.
12
+
13
+ Tarifs Anthropic (Claude Sonnet) :
14
+ Input standard : 3,00 $/M tokens
15
+ Cache write : 3,75 $/M tokens (premier appel — mise en cache)
16
+ Cache read : 0,30 $/M tokens (appels suivants — hits)
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ # ─── tarifs Anthropic ────────────────────────────────────────────────────────
22
+ PRICE_INPUT_USD_PER_M: float = 3.00 # tokens input standard
23
+ PRICE_WRITE_USD_PER_M: float = 3.75 # cache write (premier appel)
24
+ PRICE_READ_USD_PER_M: float = 0.30 # cache read (hits suivants)
25
+
26
+
27
+ class CacheInjector:
28
+ """Injecte cache_control dans les tools MCP et calcule les économies Anthropic."""
29
+
30
+ # ── injection ─────────────────────────────────────────────────────────── #
31
+
32
+ @staticmethod
33
+ def inject_cache_control(tools: list[dict]) -> list[dict]:
34
+ """Ajoute ``cache_control: {type: ephemeral}`` sur le dernier tool de la liste.
35
+
36
+ C'est la règle Anthropic : on marque le *dernier* élément du bloc
37
+ qu'on veut mettre en cache. Tous les éléments précédents sont inclus
38
+ automatiquement dans le cache.
39
+
40
+ Les tools MCP ne changent pas au fil d'une session Refract → caching
41
+ 100 % efficace dès le deuxième appel.
42
+
43
+ Args:
44
+ tools: liste de dicts tools MCP (format Anthropic API ou MCP brut).
45
+ Chaque dict contient au minimum ``"name"`` et ``"inputSchema"``.
46
+
47
+ Returns:
48
+ Nouvelle liste (shallow copy) avec cache_control sur le dernier élément.
49
+ Retourne la liste inchangée si elle est vide.
50
+ """
51
+ if not tools:
52
+ return tools
53
+ result = [dict(t) for t in tools]
54
+ result[-1] = {**result[-1], "cache_control": {"type": "ephemeral"}}
55
+ return result
56
+
57
+ # ── estimation des économies ──────────────────────────────────────────── #
58
+
59
+ @staticmethod
60
+ def estimate_cache_savings(
61
+ tokens: int,
62
+ requests_per_day: int,
63
+ days: int = 30,
64
+ ) -> dict:
65
+ """Calcule les économies de la combinaison Refract + prompt cache Anthropic.
66
+
67
+ Compare deux scénarios sur ``days`` jours :
68
+
69
+ **Scénario A — sans cache, sans Refract** : on envoie ``tokens`` tokens
70
+ à chaque requête au tarif input standard (3,00 $/M).
71
+
72
+ **Scénario B — avec cache, avec Refract** : premier appel = cache write
73
+ (3,75 $/M), appels suivants = cache read (0,30 $/M). Les tokens ici
74
+ sont ceux déjà compressés par Refract — la fonction attend donc le
75
+ compte *après* compression.
76
+
77
+ Args:
78
+ tokens: nombre de tokens des schémas (après compression Refract).
79
+ requests_per_day: nombre de requêtes agent par jour.
80
+ days: durée de la simulation (défaut : 30 jours).
81
+
82
+ Returns:
83
+ dict avec :
84
+ - ``cout_sans_cache_sans_refract`` (float) : coût scénario A en USD
85
+ - ``cout_avec_cache_avec_refract`` (float) : coût scénario B en USD
86
+ - ``economie_totale_usd`` (float) : A − B, toujours ≥ 0
87
+ - ``reduction_pct`` (float) : réduction relative en %
88
+ """
89
+ total_requests = requests_per_day * days
90
+
91
+ # Scénario A : tokens × 3$/M × total_requêtes
92
+ cout_sans = tokens * PRICE_INPUT_USD_PER_M / 1_000_000 * total_requests
93
+
94
+ # Scénario B : cache write (1er appel) + cache reads (appels suivants)
95
+ cout_avec = (
96
+ tokens * PRICE_WRITE_USD_PER_M / 1_000_000
97
+ + tokens * PRICE_READ_USD_PER_M / 1_000_000 * max(0, total_requests - 1)
98
+ )
99
+
100
+ economie = max(0.0, cout_sans - cout_avec)
101
+ reduction_pct = round(economie / cout_sans * 100, 1) if cout_sans else 0.0
102
+
103
+ return {
104
+ "cout_sans_cache_sans_refract": round(cout_sans, 6),
105
+ "cout_avec_cache_avec_refract": round(cout_avec, 6),
106
+ "economie_totale_usd": round(economie, 6),
107
+ "reduction_pct": reduction_pct,
108
+ }
109
+
110
+ # ── helpers format Anthropic API ──────────────────────────────────────── #
111
+
112
+ @staticmethod
113
+ def to_anthropic_format(tools: list[dict], use_cache: bool = True) -> list[dict]:
114
+ """Convertit des tools MCP (inputSchema) en format Anthropic API (input_schema).
115
+
116
+ Le champ s'appelle ``inputSchema`` en MCP et ``input_schema`` dans l'API
117
+ Anthropic. Cette méthode renomme le champ et injecte optionnellement
118
+ ``cache_control`` sur le dernier tool.
119
+
120
+ Args:
121
+ tools: liste de dicts MCP (``name``, ``description``, ``inputSchema``).
122
+ use_cache: si True, injecte cache_control sur le dernier tool.
123
+
124
+ Returns:
125
+ Liste de dicts au format Anthropic API.
126
+ """
127
+ converted = [
128
+ {
129
+ "name": t.get("name", ""),
130
+ "description": t.get("description", ""),
131
+ "input_schema": t.get("inputSchema", t.get("input_schema", {})),
132
+ }
133
+ for t in tools
134
+ ]
135
+ return CacheInjector.inject_cache_control(converted) if use_cache else converted