@allansantos-dev/smart-tool 0.9.0 → 0.9.2
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.
- package/CHANGELOG.md +50 -23
- package/NOTICE +32 -32
- package/README.md +117 -116
- package/atomic_io.py +94 -94
- package/bin/smart-tool.mjs +5 -1
- package/client_hooks.py +192 -192
- package/code_graph_java.mjs +49 -49
- package/code_graph_js.cjs +135 -135
- package/duplicates.py +313 -313
- package/embedding_cache.py +99 -99
- package/endpoint_sync.py +179 -179
- package/gateway.py +220 -220
- package/hook_decision.py +96 -96
- package/index_inventory.py +75 -75
- package/indexer.py +878 -878
- package/install.py +113 -11
- package/install_runtime.py +101 -35
- package/integration_metrics.py +82 -82
- package/local_embedder.py +172 -172
- package/model_defaults.py +70 -70
- package/package.json +35 -35
- package/project_insights_ui.py +38 -38
- package/project_monitor.py +204 -204
- package/project_store.py +220 -220
- package/projects_ui.py +146 -146
- package/requirements.txt +8 -8
- package/research_cache.py +171 -171
- package/research_metrics.py +110 -110
- package/router.py +582 -582
- package/secret_store.py +121 -121
- package/setup_ui.py +1011 -986
- package/smart_tool_daemon.py +3604 -3604
- package/version.py +1 -1
- package/web_adapters/browser/fetch_camoufox.py +69 -0
- package/web_adapters/node/ddg-search.mjs +89 -89
- package/web_adapters/node/open-websearch-adapter.mjs +77 -77
- package/web_adapters/node/package.json +12 -12
- package/web_adapters/node/wikipedia-search.mjs +44 -44
- package/web_search_adapters.py +193 -193
- package/web_search_external.py +288 -288
- package/web_search_health.py +88 -88
package/gateway.py
CHANGED
|
@@ -1,220 +1,220 @@
|
|
|
1
|
-
"""Model gateway through adapters: how Smart Tool reaches embedding, chat and (optionally) rerank models.
|
|
2
|
-
|
|
3
|
-
Pattern copied from OpenCode providers (implementation + options, credentials kept apart; opencode.ai/docs/providers)
|
|
4
|
-
and LiteLLM custom handlers (a fixed set of methods registered by name; docs.litellm.ai custom_llm_server).
|
|
5
|
-
|
|
6
|
-
An adapter is a Python module with:
|
|
7
|
-
NAME unique id (str)
|
|
8
|
-
LABEL name shown in the setup screen
|
|
9
|
-
FIELDS sequence of {"name", "label", optional "secret", "required", "placeholder", "hint"}
|
|
10
|
-
request(options, route, body=None, timeout=15) -> (json, headers)
|
|
11
|
-
route is OpenAI-style: "models", "model/info", "embeddings", "chat/completions", "rerank"; body None = GET.
|
|
12
|
-
HTTP errors propagate as urllib.error.HTTPError.
|
|
13
|
-
verify(options) -> None, raising RuntimeError with a reason a person can act on.
|
|
14
|
-
|
|
15
|
-
Built-in adapters live in model_adapters/; any .py in ADAPTERS_DIR is loaded as a user adapter. Non-secret options
|
|
16
|
-
stay in config.json; secret fields go to the DPAPI key store.
|
|
17
|
-
"""
|
|
18
|
-
import importlib.util
|
|
19
|
-
import os
|
|
20
|
-
import threading
|
|
21
|
-
import time
|
|
22
|
-
|
|
23
|
-
import config
|
|
24
|
-
import secret_store
|
|
25
|
-
from model_adapters import openai_compatible
|
|
26
|
-
|
|
27
|
-
ADAPTERS_DIR = os.path.join(config.CONFIG_DIR, "adapters")
|
|
28
|
-
BUILTIN = (openai_compatible,)
|
|
29
|
-
STATUS_TTL_S = 30
|
|
30
|
-
_lock = threading.Lock()
|
|
31
|
-
_loaded = {"key": None, "adapters": {}, "errors": []}
|
|
32
|
-
_status = {"at": 0.0, "key": None, "value": None}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def _check(module, origin):
|
|
36
|
-
for attribute in ("NAME", "LABEL", "FIELDS"):
|
|
37
|
-
if not getattr(module, attribute, None):
|
|
38
|
-
raise ValueError(f"{origin}: missing {attribute}")
|
|
39
|
-
for function in ("request", "verify"):
|
|
40
|
-
if not callable(getattr(module, function, None)):
|
|
41
|
-
raise ValueError(f"{origin}: missing function {function}()")
|
|
42
|
-
for field in module.FIELDS:
|
|
43
|
-
if not isinstance(field, dict) or not field.get("name") or not field.get("label"):
|
|
44
|
-
raise ValueError(f"{origin}: each FIELDS entry needs name and label")
|
|
45
|
-
return module
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def adapters():
|
|
49
|
-
"""({name: module}, [load errors]); user adapters are re-imported only when their files change."""
|
|
50
|
-
errors, stamped = [], []
|
|
51
|
-
try:
|
|
52
|
-
names = sorted(name for name in os.listdir(ADAPTERS_DIR) if name.endswith(".py"))
|
|
53
|
-
except FileNotFoundError:
|
|
54
|
-
names = []
|
|
55
|
-
except OSError as exc:
|
|
56
|
-
names, errors = [], [f"{ADAPTERS_DIR}: {type(exc).__name__}: {exc}"]
|
|
57
|
-
for name in names:
|
|
58
|
-
path = os.path.join(ADAPTERS_DIR, name)
|
|
59
|
-
try:
|
|
60
|
-
stamped.append((path, os.path.getmtime(path)))
|
|
61
|
-
except OSError:
|
|
62
|
-
continue
|
|
63
|
-
key = (tuple(stamped), tuple(errors))
|
|
64
|
-
with _lock:
|
|
65
|
-
if _loaded["key"] == key:
|
|
66
|
-
return dict(_loaded["adapters"]), list(_loaded["errors"])
|
|
67
|
-
# Imported outside the lock: an adapter that calls back into gateway at import time must not deadlock the daemon.
|
|
68
|
-
found = {module.NAME: module for module in BUILTIN}
|
|
69
|
-
for path, _mtime in stamped:
|
|
70
|
-
try:
|
|
71
|
-
spec = importlib.util.spec_from_file_location(
|
|
72
|
-
"smart_tool_adapter_" + os.path.splitext(os.path.basename(path))[0], path)
|
|
73
|
-
module = importlib.util.module_from_spec(spec)
|
|
74
|
-
spec.loader.exec_module(module)
|
|
75
|
-
_check(module, path)
|
|
76
|
-
if module.NAME in found:
|
|
77
|
-
raise ValueError(f"{path}: NAME {module.NAME!r} already exists")
|
|
78
|
-
found[module.NAME] = module
|
|
79
|
-
except Exception as exc:
|
|
80
|
-
errors.append(f"{os.path.basename(path)}: {type(exc).__name__}: {exc}")
|
|
81
|
-
with _lock:
|
|
82
|
-
_loaded.update(key=key, adapters=found, errors=errors)
|
|
83
|
-
return dict(found), list(errors)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def _secret_name(adapter, field):
|
|
87
|
-
return f"adapter:{adapter}:{field}"
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def selected():
|
|
91
|
-
"""(adapter module or None, options with secrets in clear). Unknown adapter in config fails loudly."""
|
|
92
|
-
cfg = config.load_config()
|
|
93
|
-
name = cfg.get("model_adapter") or ""
|
|
94
|
-
if not name:
|
|
95
|
-
return None, {}
|
|
96
|
-
found, errors = adapters()
|
|
97
|
-
if name not in found:
|
|
98
|
-
raise RuntimeError(f"Model adapter {name!r} not found" + (f" ({'; '.join(errors)})" if errors else ""))
|
|
99
|
-
module = found[name]
|
|
100
|
-
options = dict((cfg.get("model_adapter_options") or {}).get(name) or {})
|
|
101
|
-
for field in module.FIELDS:
|
|
102
|
-
if field.get("secret"):
|
|
103
|
-
options[field["name"]] = config.get_provider_api_key(_secret_name(name, field["name"]))
|
|
104
|
-
return module, options
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
def describe():
|
|
108
|
-
"""Setup screen view: adapters with fields, stored non-secret values and secret state; never the secrets."""
|
|
109
|
-
cfg = config.load_config()
|
|
110
|
-
found, errors = adapters()
|
|
111
|
-
stored = cfg.get("model_adapter_options") or {}
|
|
112
|
-
listing = []
|
|
113
|
-
for name, module in found.items():
|
|
114
|
-
fields = []
|
|
115
|
-
for field in module.FIELDS:
|
|
116
|
-
entry = {key: field.get(key) for key in ("name", "label", "secret", "required", "placeholder", "hint")}
|
|
117
|
-
if field.get("secret"):
|
|
118
|
-
entry["state"] = config.provider_key_status(_secret_name(name, field["name"]))
|
|
119
|
-
else:
|
|
120
|
-
entry["value"] = (stored.get(name) or {}).get(field["name"], "")
|
|
121
|
-
fields.append(entry)
|
|
122
|
-
listing.append({"name": name, "label": module.LABEL, "builtin": module in BUILTIN, "fields": fields})
|
|
123
|
-
return {"selected": cfg.get("model_adapter") or "", "adapters": listing, "errors": errors, "dir": ADAPTERS_DIR}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
def save(name, values):
|
|
127
|
-
"""Verifies with the adapter before storing. Secret field absent/None keeps the stored value; "" removes it.
|
|
128
|
-
name "" disconnects."""
|
|
129
|
-
if not name:
|
|
130
|
-
config.update_config(lambda cfg: cfg.update(model_adapter=""))
|
|
131
|
-
forget_status()
|
|
132
|
-
return
|
|
133
|
-
found, _errors = adapters()
|
|
134
|
-
if name not in found:
|
|
135
|
-
raise ValueError(f"Adapter {name!r} does not exist.")
|
|
136
|
-
module, values = found[name], values or {}
|
|
137
|
-
plain, secrets, options = {}, {}, {}
|
|
138
|
-
for field in module.FIELDS:
|
|
139
|
-
key = field["name"]
|
|
140
|
-
value = values.get(key)
|
|
141
|
-
if field.get("secret"):
|
|
142
|
-
if value is None:
|
|
143
|
-
try:
|
|
144
|
-
value = config.get_provider_api_key(_secret_name(name, key))
|
|
145
|
-
except secret_store.SecretProtectionError:
|
|
146
|
-
raise ValueError(f"The saved value of {field['label']} cannot be opened in this Windows profile; "
|
|
147
|
-
"paste it again.") from None
|
|
148
|
-
else:
|
|
149
|
-
secrets[key] = str(value).strip()
|
|
150
|
-
value = secrets[key]
|
|
151
|
-
if value:
|
|
152
|
-
config.check_provider_key_format(value)
|
|
153
|
-
else:
|
|
154
|
-
value = str(value or "").strip()
|
|
155
|
-
plain[key] = value
|
|
156
|
-
if field.get("required") and not value:
|
|
157
|
-
raise ValueError(f"Fill in {field['label']}.")
|
|
158
|
-
options[key] = value
|
|
159
|
-
module.verify(options)
|
|
160
|
-
|
|
161
|
-
def mutate(cfg):
|
|
162
|
-
stored = dict(cfg.get("model_adapter_options") or {})
|
|
163
|
-
stored[name] = plain
|
|
164
|
-
cfg.update(model_adapter=name, model_adapter_options=stored)
|
|
165
|
-
config.update_config(mutate)
|
|
166
|
-
for key, value in secrets.items():
|
|
167
|
-
config.set_provider_api_key(_secret_name(name, key), value)
|
|
168
|
-
forget_status()
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
def remove_secret(name, field):
|
|
172
|
-
"""Deletes one stored secret without testing the connection (a revoked key must be removable)."""
|
|
173
|
-
found, _errors = adapters()
|
|
174
|
-
module = found.get(name)
|
|
175
|
-
if module is None or not any(f["name"] == field and f.get("secret") for f in module.FIELDS):
|
|
176
|
-
raise ValueError(f"{name}/{field} is not a secret adapter field.")
|
|
177
|
-
config.set_provider_api_key(_secret_name(name, field), "")
|
|
178
|
-
forget_status()
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
def forget_status():
|
|
182
|
-
with _lock:
|
|
183
|
-
_status.update(at=0.0, key=None, value=None)
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
def status():
|
|
187
|
-
"""{"status": "ready" | "not_configured" | "error", "adapter", "label", "error"}, checked every STATUS_TTL_S."""
|
|
188
|
-
try:
|
|
189
|
-
module, options = selected()
|
|
190
|
-
except Exception as exc:
|
|
191
|
-
return {"status": "error", "adapter": config.load_config().get("model_adapter"), "label": "",
|
|
192
|
-
"error": f"{type(exc).__name__}: {exc}"}
|
|
193
|
-
if module is None:
|
|
194
|
-
return {"status": "not_configured", "adapter": "", "label": "",
|
|
195
|
-
"error": "No model gateway configured."}
|
|
196
|
-
key = (module.NAME, tuple(sorted(options.items())))
|
|
197
|
-
with _lock:
|
|
198
|
-
if _status["key"] == key and time.monotonic() - _status["at"] < STATUS_TTL_S:
|
|
199
|
-
return dict(_status["value"])
|
|
200
|
-
try:
|
|
201
|
-
module.verify(options)
|
|
202
|
-
value = {"status": "ready", "adapter": module.NAME, "label": module.LABEL, "error": ""}
|
|
203
|
-
except Exception as exc:
|
|
204
|
-
value = {"status": "error", "adapter": module.NAME, "label": module.LABEL, "error": str(exc)}
|
|
205
|
-
with _lock:
|
|
206
|
-
_status.update(at=time.monotonic(), key=key, value=value)
|
|
207
|
-
return dict(value)
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
def fingerprint():
|
|
211
|
-
"""Changes whenever the adapter or its options change (cache key for the model catalog)."""
|
|
212
|
-
module, options = selected()
|
|
213
|
-
return (module.NAME if module else "", tuple(sorted(options.items())))
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
def request(route, body=None, timeout=15):
|
|
217
|
-
module, options = selected()
|
|
218
|
-
if module is None:
|
|
219
|
-
raise RuntimeError("No model gateway configured: choose an adapter in the setup screen.")
|
|
220
|
-
return module.request(options, route, body=body, timeout=timeout)
|
|
1
|
+
"""Model gateway through adapters: how Smart Tool reaches embedding, chat and (optionally) rerank models.
|
|
2
|
+
|
|
3
|
+
Pattern copied from OpenCode providers (implementation + options, credentials kept apart; opencode.ai/docs/providers)
|
|
4
|
+
and LiteLLM custom handlers (a fixed set of methods registered by name; docs.litellm.ai custom_llm_server).
|
|
5
|
+
|
|
6
|
+
An adapter is a Python module with:
|
|
7
|
+
NAME unique id (str)
|
|
8
|
+
LABEL name shown in the setup screen
|
|
9
|
+
FIELDS sequence of {"name", "label", optional "secret", "required", "placeholder", "hint"}
|
|
10
|
+
request(options, route, body=None, timeout=15) -> (json, headers)
|
|
11
|
+
route is OpenAI-style: "models", "model/info", "embeddings", "chat/completions", "rerank"; body None = GET.
|
|
12
|
+
HTTP errors propagate as urllib.error.HTTPError.
|
|
13
|
+
verify(options) -> None, raising RuntimeError with a reason a person can act on.
|
|
14
|
+
|
|
15
|
+
Built-in adapters live in model_adapters/; any .py in ADAPTERS_DIR is loaded as a user adapter. Non-secret options
|
|
16
|
+
stay in config.json; secret fields go to the DPAPI key store.
|
|
17
|
+
"""
|
|
18
|
+
import importlib.util
|
|
19
|
+
import os
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
import config
|
|
24
|
+
import secret_store
|
|
25
|
+
from model_adapters import openai_compatible
|
|
26
|
+
|
|
27
|
+
ADAPTERS_DIR = os.path.join(config.CONFIG_DIR, "adapters")
|
|
28
|
+
BUILTIN = (openai_compatible,)
|
|
29
|
+
STATUS_TTL_S = 30
|
|
30
|
+
_lock = threading.Lock()
|
|
31
|
+
_loaded = {"key": None, "adapters": {}, "errors": []}
|
|
32
|
+
_status = {"at": 0.0, "key": None, "value": None}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _check(module, origin):
|
|
36
|
+
for attribute in ("NAME", "LABEL", "FIELDS"):
|
|
37
|
+
if not getattr(module, attribute, None):
|
|
38
|
+
raise ValueError(f"{origin}: missing {attribute}")
|
|
39
|
+
for function in ("request", "verify"):
|
|
40
|
+
if not callable(getattr(module, function, None)):
|
|
41
|
+
raise ValueError(f"{origin}: missing function {function}()")
|
|
42
|
+
for field in module.FIELDS:
|
|
43
|
+
if not isinstance(field, dict) or not field.get("name") or not field.get("label"):
|
|
44
|
+
raise ValueError(f"{origin}: each FIELDS entry needs name and label")
|
|
45
|
+
return module
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def adapters():
|
|
49
|
+
"""({name: module}, [load errors]); user adapters are re-imported only when their files change."""
|
|
50
|
+
errors, stamped = [], []
|
|
51
|
+
try:
|
|
52
|
+
names = sorted(name for name in os.listdir(ADAPTERS_DIR) if name.endswith(".py"))
|
|
53
|
+
except FileNotFoundError:
|
|
54
|
+
names = []
|
|
55
|
+
except OSError as exc:
|
|
56
|
+
names, errors = [], [f"{ADAPTERS_DIR}: {type(exc).__name__}: {exc}"]
|
|
57
|
+
for name in names:
|
|
58
|
+
path = os.path.join(ADAPTERS_DIR, name)
|
|
59
|
+
try:
|
|
60
|
+
stamped.append((path, os.path.getmtime(path)))
|
|
61
|
+
except OSError:
|
|
62
|
+
continue
|
|
63
|
+
key = (tuple(stamped), tuple(errors))
|
|
64
|
+
with _lock:
|
|
65
|
+
if _loaded["key"] == key:
|
|
66
|
+
return dict(_loaded["adapters"]), list(_loaded["errors"])
|
|
67
|
+
# Imported outside the lock: an adapter that calls back into gateway at import time must not deadlock the daemon.
|
|
68
|
+
found = {module.NAME: module for module in BUILTIN}
|
|
69
|
+
for path, _mtime in stamped:
|
|
70
|
+
try:
|
|
71
|
+
spec = importlib.util.spec_from_file_location(
|
|
72
|
+
"smart_tool_adapter_" + os.path.splitext(os.path.basename(path))[0], path)
|
|
73
|
+
module = importlib.util.module_from_spec(spec)
|
|
74
|
+
spec.loader.exec_module(module)
|
|
75
|
+
_check(module, path)
|
|
76
|
+
if module.NAME in found:
|
|
77
|
+
raise ValueError(f"{path}: NAME {module.NAME!r} already exists")
|
|
78
|
+
found[module.NAME] = module
|
|
79
|
+
except Exception as exc:
|
|
80
|
+
errors.append(f"{os.path.basename(path)}: {type(exc).__name__}: {exc}")
|
|
81
|
+
with _lock:
|
|
82
|
+
_loaded.update(key=key, adapters=found, errors=errors)
|
|
83
|
+
return dict(found), list(errors)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _secret_name(adapter, field):
|
|
87
|
+
return f"adapter:{adapter}:{field}"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def selected():
|
|
91
|
+
"""(adapter module or None, options with secrets in clear). Unknown adapter in config fails loudly."""
|
|
92
|
+
cfg = config.load_config()
|
|
93
|
+
name = cfg.get("model_adapter") or ""
|
|
94
|
+
if not name:
|
|
95
|
+
return None, {}
|
|
96
|
+
found, errors = adapters()
|
|
97
|
+
if name not in found:
|
|
98
|
+
raise RuntimeError(f"Model adapter {name!r} not found" + (f" ({'; '.join(errors)})" if errors else ""))
|
|
99
|
+
module = found[name]
|
|
100
|
+
options = dict((cfg.get("model_adapter_options") or {}).get(name) or {})
|
|
101
|
+
for field in module.FIELDS:
|
|
102
|
+
if field.get("secret"):
|
|
103
|
+
options[field["name"]] = config.get_provider_api_key(_secret_name(name, field["name"]))
|
|
104
|
+
return module, options
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def describe():
|
|
108
|
+
"""Setup screen view: adapters with fields, stored non-secret values and secret state; never the secrets."""
|
|
109
|
+
cfg = config.load_config()
|
|
110
|
+
found, errors = adapters()
|
|
111
|
+
stored = cfg.get("model_adapter_options") or {}
|
|
112
|
+
listing = []
|
|
113
|
+
for name, module in found.items():
|
|
114
|
+
fields = []
|
|
115
|
+
for field in module.FIELDS:
|
|
116
|
+
entry = {key: field.get(key) for key in ("name", "label", "secret", "required", "placeholder", "hint")}
|
|
117
|
+
if field.get("secret"):
|
|
118
|
+
entry["state"] = config.provider_key_status(_secret_name(name, field["name"]))
|
|
119
|
+
else:
|
|
120
|
+
entry["value"] = (stored.get(name) or {}).get(field["name"], "")
|
|
121
|
+
fields.append(entry)
|
|
122
|
+
listing.append({"name": name, "label": module.LABEL, "builtin": module in BUILTIN, "fields": fields})
|
|
123
|
+
return {"selected": cfg.get("model_adapter") or "", "adapters": listing, "errors": errors, "dir": ADAPTERS_DIR}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def save(name, values):
|
|
127
|
+
"""Verifies with the adapter before storing. Secret field absent/None keeps the stored value; "" removes it.
|
|
128
|
+
name "" disconnects."""
|
|
129
|
+
if not name:
|
|
130
|
+
config.update_config(lambda cfg: cfg.update(model_adapter=""))
|
|
131
|
+
forget_status()
|
|
132
|
+
return
|
|
133
|
+
found, _errors = adapters()
|
|
134
|
+
if name not in found:
|
|
135
|
+
raise ValueError(f"Adapter {name!r} does not exist.")
|
|
136
|
+
module, values = found[name], values or {}
|
|
137
|
+
plain, secrets, options = {}, {}, {}
|
|
138
|
+
for field in module.FIELDS:
|
|
139
|
+
key = field["name"]
|
|
140
|
+
value = values.get(key)
|
|
141
|
+
if field.get("secret"):
|
|
142
|
+
if value is None:
|
|
143
|
+
try:
|
|
144
|
+
value = config.get_provider_api_key(_secret_name(name, key))
|
|
145
|
+
except secret_store.SecretProtectionError:
|
|
146
|
+
raise ValueError(f"The saved value of {field['label']} cannot be opened in this Windows profile; "
|
|
147
|
+
"paste it again.") from None
|
|
148
|
+
else:
|
|
149
|
+
secrets[key] = str(value).strip()
|
|
150
|
+
value = secrets[key]
|
|
151
|
+
if value:
|
|
152
|
+
config.check_provider_key_format(value)
|
|
153
|
+
else:
|
|
154
|
+
value = str(value or "").strip()
|
|
155
|
+
plain[key] = value
|
|
156
|
+
if field.get("required") and not value:
|
|
157
|
+
raise ValueError(f"Fill in {field['label']}.")
|
|
158
|
+
options[key] = value
|
|
159
|
+
module.verify(options)
|
|
160
|
+
|
|
161
|
+
def mutate(cfg):
|
|
162
|
+
stored = dict(cfg.get("model_adapter_options") or {})
|
|
163
|
+
stored[name] = plain
|
|
164
|
+
cfg.update(model_adapter=name, model_adapter_options=stored)
|
|
165
|
+
config.update_config(mutate)
|
|
166
|
+
for key, value in secrets.items():
|
|
167
|
+
config.set_provider_api_key(_secret_name(name, key), value)
|
|
168
|
+
forget_status()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def remove_secret(name, field):
|
|
172
|
+
"""Deletes one stored secret without testing the connection (a revoked key must be removable)."""
|
|
173
|
+
found, _errors = adapters()
|
|
174
|
+
module = found.get(name)
|
|
175
|
+
if module is None or not any(f["name"] == field and f.get("secret") for f in module.FIELDS):
|
|
176
|
+
raise ValueError(f"{name}/{field} is not a secret adapter field.")
|
|
177
|
+
config.set_provider_api_key(_secret_name(name, field), "")
|
|
178
|
+
forget_status()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def forget_status():
|
|
182
|
+
with _lock:
|
|
183
|
+
_status.update(at=0.0, key=None, value=None)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def status():
|
|
187
|
+
"""{"status": "ready" | "not_configured" | "error", "adapter", "label", "error"}, checked every STATUS_TTL_S."""
|
|
188
|
+
try:
|
|
189
|
+
module, options = selected()
|
|
190
|
+
except Exception as exc:
|
|
191
|
+
return {"status": "error", "adapter": config.load_config().get("model_adapter"), "label": "",
|
|
192
|
+
"error": f"{type(exc).__name__}: {exc}"}
|
|
193
|
+
if module is None:
|
|
194
|
+
return {"status": "not_configured", "adapter": "", "label": "",
|
|
195
|
+
"error": "No model gateway configured."}
|
|
196
|
+
key = (module.NAME, tuple(sorted(options.items())))
|
|
197
|
+
with _lock:
|
|
198
|
+
if _status["key"] == key and time.monotonic() - _status["at"] < STATUS_TTL_S:
|
|
199
|
+
return dict(_status["value"])
|
|
200
|
+
try:
|
|
201
|
+
module.verify(options)
|
|
202
|
+
value = {"status": "ready", "adapter": module.NAME, "label": module.LABEL, "error": ""}
|
|
203
|
+
except Exception as exc:
|
|
204
|
+
value = {"status": "error", "adapter": module.NAME, "label": module.LABEL, "error": str(exc)}
|
|
205
|
+
with _lock:
|
|
206
|
+
_status.update(at=time.monotonic(), key=key, value=value)
|
|
207
|
+
return dict(value)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def fingerprint():
|
|
211
|
+
"""Changes whenever the adapter or its options change (cache key for the model catalog)."""
|
|
212
|
+
module, options = selected()
|
|
213
|
+
return (module.NAME if module else "", tuple(sorted(options.items())))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def request(route, body=None, timeout=15):
|
|
217
|
+
module, options = selected()
|
|
218
|
+
if module is None:
|
|
219
|
+
raise RuntimeError("No model gateway configured: choose an adapter in the setup screen.")
|
|
220
|
+
return module.request(options, route, body=body, timeout=timeout)
|
package/hook_decision.py
CHANGED
|
@@ -1,96 +1,96 @@
|
|
|
1
|
-
"""Decisão do PreToolUse do Smart Tool, tomada dentro do daemon já aquecido.
|
|
2
|
-
|
|
3
|
-
O Claude Code chama a rota POST /hooks/pretool como hook `type: "http"` (sem processo por chamada); o Codex usa o
|
|
4
|
-
hook de comando `hooks/pretool_router.py`, que só repassa o payload para a mesma rota.
|
|
5
|
-
|
|
6
|
-
INVARIANTE: a saída só pode ser `{}` (neutra) ou `deny`. `permissionDecision: "allow"` pula a confirmação do usuário
|
|
7
|
-
no Claude Code, e nenhuma decisão aqui é sobre periculosidade, só sobre custo de contexto. Diagnóstico vai em
|
|
8
|
-
`systemMessage`, que não altera permissão.
|
|
9
|
-
"""
|
|
10
|
-
import json
|
|
11
|
-
import os
|
|
12
|
-
|
|
13
|
-
import config
|
|
14
|
-
import endpoint_sync
|
|
15
|
-
import router
|
|
16
|
-
|
|
17
|
-
WEB_TOOLS = ("WebSearch", "WebFetch")
|
|
18
|
-
SMART_TOOL_SERVER = "smart-tool"
|
|
19
|
-
REASON_MAX_CHARS = 200
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def clean_reason(reason):
|
|
23
|
-
"""The reason is free LLM text fed to the host model: one line, bounded (repository content reaches the router
|
|
24
|
-
prompt through tool_input)."""
|
|
25
|
-
text = " ".join(str(reason or "").split())
|
|
26
|
-
return text[:REASON_MAX_CHARS] or "call broad enough to be worth an indexed search"
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def deny(reason):
|
|
30
|
-
return {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny",
|
|
31
|
-
"permissionDecisionReason": reason}}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def smart_tool_in_session(cwd):
|
|
35
|
-
"""Claude Code loads MCP servers from ~/.claude.json (user and per project) and the project's .mcp.json."""
|
|
36
|
-
with open(endpoint_sync.claude_json(), encoding="utf-8") as stream:
|
|
37
|
-
data = json.load(stream)
|
|
38
|
-
key = os.path.normcase(os.path.normpath(cwd or ""))
|
|
39
|
-
project = next((value for path, value in (data.get("projects") or {}).items()
|
|
40
|
-
if os.path.normcase(os.path.normpath(path)) == key), {})
|
|
41
|
-
if SMART_TOOL_SERVER in (project.get("disabledMcpServers") or []):
|
|
42
|
-
return False
|
|
43
|
-
if SMART_TOOL_SERVER in (data.get("mcpServers") or {}) or SMART_TOOL_SERVER in (project.get("mcpServers") or {}):
|
|
44
|
-
return True
|
|
45
|
-
try:
|
|
46
|
-
with open(os.path.join(cwd or "", ".mcp.json"), encoding="utf-8") as stream:
|
|
47
|
-
return SMART_TOOL_SERVER in (json.load(stream).get("mcpServers") or {})
|
|
48
|
-
except (OSError, ValueError):
|
|
49
|
-
return False
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def advise(reason):
|
|
53
|
-
"""Same routing advice without blocking: the agent sees it next to the tool result and decides next time."""
|
|
54
|
-
return {"hookSpecificOutput": {"hookEventName": "PreToolUse",
|
|
55
|
-
"additionalContext": f"Smart Tool tip (the call was not blocked): {reason}"}}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def decide(payload, client, web_route):
|
|
59
|
-
"""Hook output for one PreToolUse payload, following config hook_mode (redirect | advise | off)."""
|
|
60
|
-
cfg = config.load_config()
|
|
61
|
-
mode = config.hook_mode(cfg)
|
|
62
|
-
if mode == "off" or not isinstance(payload, dict):
|
|
63
|
-
return {}
|
|
64
|
-
output = _route(payload, client, web_route, cfg)
|
|
65
|
-
reason = (output.get("hookSpecificOutput") or {}).get("permissionDecisionReason")
|
|
66
|
-
return advise(reason) if mode == "advise" and reason else output
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def _route(payload, client, web_route, cfg):
|
|
70
|
-
tool_name = payload.get("tool_name", "")
|
|
71
|
-
tool_input = payload.get("tool_input") if isinstance(payload.get("tool_input"), dict) else {}
|
|
72
|
-
cwd = payload.get("cwd")
|
|
73
|
-
router_model = cfg.get("router_model")
|
|
74
|
-
if not router_model:
|
|
75
|
-
return {}
|
|
76
|
-
if tool_name in WEB_TOOLS:
|
|
77
|
-
try:
|
|
78
|
-
present = smart_tool_in_session(cwd)
|
|
79
|
-
except (OSError, ValueError):
|
|
80
|
-
present = False
|
|
81
|
-
if not present:
|
|
82
|
-
return {}
|
|
83
|
-
decision = web_route(tool_name, str(tool_input.get("url") or ""))
|
|
84
|
-
return deny(clean_reason(decision.get("reason"))) if decision.get("redirect") else {}
|
|
85
|
-
router.CLIENT = client
|
|
86
|
-
try:
|
|
87
|
-
decision, reason = router.decide(tool_name, tool_input, router_model, cwd=cwd)
|
|
88
|
-
except Exception as exc:
|
|
89
|
-
try:
|
|
90
|
-
router.log_unavailable(tool_name, tool_input, exc)
|
|
91
|
-
except Exception:
|
|
92
|
-
pass
|
|
93
|
-
return {"systemMessage": f"Smart Tool unavailable, routing skipped: {type(exc).__name__}"}
|
|
94
|
-
if decision == "redirect":
|
|
95
|
-
return deny(f"Use smart_search instead of raw {tool_name}: {clean_reason(reason)}")
|
|
96
|
-
return {}
|
|
1
|
+
"""Decisão do PreToolUse do Smart Tool, tomada dentro do daemon já aquecido.
|
|
2
|
+
|
|
3
|
+
O Claude Code chama a rota POST /hooks/pretool como hook `type: "http"` (sem processo por chamada); o Codex usa o
|
|
4
|
+
hook de comando `hooks/pretool_router.py`, que só repassa o payload para a mesma rota.
|
|
5
|
+
|
|
6
|
+
INVARIANTE: a saída só pode ser `{}` (neutra) ou `deny`. `permissionDecision: "allow"` pula a confirmação do usuário
|
|
7
|
+
no Claude Code, e nenhuma decisão aqui é sobre periculosidade, só sobre custo de contexto. Diagnóstico vai em
|
|
8
|
+
`systemMessage`, que não altera permissão.
|
|
9
|
+
"""
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
import config
|
|
14
|
+
import endpoint_sync
|
|
15
|
+
import router
|
|
16
|
+
|
|
17
|
+
WEB_TOOLS = ("WebSearch", "WebFetch")
|
|
18
|
+
SMART_TOOL_SERVER = "smart-tool"
|
|
19
|
+
REASON_MAX_CHARS = 200
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def clean_reason(reason):
|
|
23
|
+
"""The reason is free LLM text fed to the host model: one line, bounded (repository content reaches the router
|
|
24
|
+
prompt through tool_input)."""
|
|
25
|
+
text = " ".join(str(reason or "").split())
|
|
26
|
+
return text[:REASON_MAX_CHARS] or "call broad enough to be worth an indexed search"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def deny(reason):
|
|
30
|
+
return {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny",
|
|
31
|
+
"permissionDecisionReason": reason}}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def smart_tool_in_session(cwd):
|
|
35
|
+
"""Claude Code loads MCP servers from ~/.claude.json (user and per project) and the project's .mcp.json."""
|
|
36
|
+
with open(endpoint_sync.claude_json(), encoding="utf-8") as stream:
|
|
37
|
+
data = json.load(stream)
|
|
38
|
+
key = os.path.normcase(os.path.normpath(cwd or ""))
|
|
39
|
+
project = next((value for path, value in (data.get("projects") or {}).items()
|
|
40
|
+
if os.path.normcase(os.path.normpath(path)) == key), {})
|
|
41
|
+
if SMART_TOOL_SERVER in (project.get("disabledMcpServers") or []):
|
|
42
|
+
return False
|
|
43
|
+
if SMART_TOOL_SERVER in (data.get("mcpServers") or {}) or SMART_TOOL_SERVER in (project.get("mcpServers") or {}):
|
|
44
|
+
return True
|
|
45
|
+
try:
|
|
46
|
+
with open(os.path.join(cwd or "", ".mcp.json"), encoding="utf-8") as stream:
|
|
47
|
+
return SMART_TOOL_SERVER in (json.load(stream).get("mcpServers") or {})
|
|
48
|
+
except (OSError, ValueError):
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def advise(reason):
|
|
53
|
+
"""Same routing advice without blocking: the agent sees it next to the tool result and decides next time."""
|
|
54
|
+
return {"hookSpecificOutput": {"hookEventName": "PreToolUse",
|
|
55
|
+
"additionalContext": f"Smart Tool tip (the call was not blocked): {reason}"}}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def decide(payload, client, web_route):
|
|
59
|
+
"""Hook output for one PreToolUse payload, following config hook_mode (redirect | advise | off)."""
|
|
60
|
+
cfg = config.load_config()
|
|
61
|
+
mode = config.hook_mode(cfg)
|
|
62
|
+
if mode == "off" or not isinstance(payload, dict):
|
|
63
|
+
return {}
|
|
64
|
+
output = _route(payload, client, web_route, cfg)
|
|
65
|
+
reason = (output.get("hookSpecificOutput") or {}).get("permissionDecisionReason")
|
|
66
|
+
return advise(reason) if mode == "advise" and reason else output
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _route(payload, client, web_route, cfg):
|
|
70
|
+
tool_name = payload.get("tool_name", "")
|
|
71
|
+
tool_input = payload.get("tool_input") if isinstance(payload.get("tool_input"), dict) else {}
|
|
72
|
+
cwd = payload.get("cwd")
|
|
73
|
+
router_model = cfg.get("router_model")
|
|
74
|
+
if not router_model:
|
|
75
|
+
return {}
|
|
76
|
+
if tool_name in WEB_TOOLS:
|
|
77
|
+
try:
|
|
78
|
+
present = smart_tool_in_session(cwd)
|
|
79
|
+
except (OSError, ValueError):
|
|
80
|
+
present = False
|
|
81
|
+
if not present:
|
|
82
|
+
return {}
|
|
83
|
+
decision = web_route(tool_name, str(tool_input.get("url") or ""))
|
|
84
|
+
return deny(clean_reason(decision.get("reason"))) if decision.get("redirect") else {}
|
|
85
|
+
router.CLIENT = client
|
|
86
|
+
try:
|
|
87
|
+
decision, reason = router.decide(tool_name, tool_input, router_model, cwd=cwd)
|
|
88
|
+
except Exception as exc:
|
|
89
|
+
try:
|
|
90
|
+
router.log_unavailable(tool_name, tool_input, exc)
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
93
|
+
return {"systemMessage": f"Smart Tool unavailable, routing skipped: {type(exc).__name__}"}
|
|
94
|
+
if decision == "redirect":
|
|
95
|
+
return deny(f"Use smart_search instead of raw {tool_name}: {clean_reason(reason)}")
|
|
96
|
+
return {}
|