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 +400 -0
- cache_injector.py +135 -0
- mcp_optimizer.py +314 -0
- mcp_signal_check.py +341 -0
- refract_cli.py +132 -0
- refract_mcp-0.1.0.dist-info/METADATA +179 -0
- refract_mcp-0.1.0.dist-info/RECORD +12 -0
- refract_mcp-0.1.0.dist-info/WHEEL +5 -0
- refract_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- refract_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
- refract_mcp-0.1.0.dist-info/top_level.txt +7 -0
- refract_proxy.py +549 -0
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
|