@allansantos-dev/smart-tool 0.9.31 → 0.9.33
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 +26 -0
- package/README.md +24 -9
- package/affected_tests.py +8 -2
- package/autostart.py +4 -0
- package/code_graph.py +4 -3
- package/code_graph_go.py +85 -0
- package/config.py +3 -0
- package/index_profile.py +4 -2
- package/model_client.py +2 -0
- package/model_defaults.py +10 -2
- package/model_health.py +163 -0
- package/model_upgrade.py +140 -0
- package/notice_action.py +58 -0
- package/package.json +1 -1
- package/setup_audit.py +106 -8
- package/setup_ui.py +119 -1
- package/smart_tool_daemon.py +28 -7
- package/toast_notice.py +99 -0
- package/tray.py +39 -0
- package/version.py +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.33 - beta
|
|
4
|
+
|
|
5
|
+
- Recommended models prefer the Vertex route. When the gateway lists a model on several routes, `model_defaults` now
|
|
6
|
+
picks `vertex_ai.*` first, any other route next and `bedrock.*` only when nothing else serves the model (Bedrock was
|
|
7
|
+
chosen before because ids were taken in alphabetical order). `DEFAULTS_VERSION` is 2, so users who already hold the
|
|
8
|
+
Bedrock ids get the "New recommended models" offer again and decide whether to apply it.
|
|
9
|
+
- `tests/vertex_route_probe.py` asks the live gateway: every recommended chat id must resolve (Anthropic ones on
|
|
10
|
+
Vertex) and answer a real request. Result of 2026-10-26 in `docs/research/2026-10-26-rota-vertex-nas-recomendacoes.md`.
|
|
11
|
+
|
|
12
|
+
## 0.9.32 - beta
|
|
13
|
+
|
|
14
|
+
- Users who updated are told about the new recommended models. `model_upgrade` compares the saved `router_model`,
|
|
15
|
+
`scope_model`, `research_model` and `scout_model` with the recommended ones against the gateway's catalog and the
|
|
16
|
+
Windows tray shows a toast with "Apply now" and "Don't show again". Applying keeps the previous models so they can be
|
|
17
|
+
restored from the setup page (card "New recommended models"); a role you changed after the offer is never overwritten;
|
|
18
|
+
closing the toast reminds again after 24 h; declining silences it for this version. Embedding and rerank models are
|
|
19
|
+
never offered (changing them invalidates every index). Buttons are protocol links run by `notice_action.py`
|
|
20
|
+
(`pythonw`, no window, HKCU only, no admin), each carrying a nonce of the offer.
|
|
21
|
+
- Model failures are logged and shown. `model_health` records every model call by hour, model and role (and, on
|
|
22
|
+
failure, tool, route and error text without the request content). A tool answer that hit a failing model now carries
|
|
23
|
+
a line with the model, the role, the error and the link to `/setup#models`; the Models page shows the last 24 h
|
|
24
|
+
(failures/calls) and the 20 latest failures; the tray raises one toast per model per day (5+ calls and 50%+ failures
|
|
25
|
+
in 24 h) with "Switch model" and "Mute 7 days". Endpoint: `GET /setup/models/health`.
|
|
26
|
+
- Tests: `tests/test_model_upgrade.py`, `tests/test_model_health.py`; research note
|
|
27
|
+
`docs/research/2026-10-24-aviso-de-modelos-novos-e-falhas-de-modelo.md`.
|
|
28
|
+
|
|
3
29
|
## 0.9.31 - beta
|
|
4
30
|
|
|
5
31
|
- `scout_model` is filled by default. The setup screen (and the daemon, when it checks the models) now fills an empty
|
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ registered as the `playwright` server where this machine lets a browser be drive
|
|
|
16
16
|
An optional hook routes the agent's native Grep/Glob/Read/Bash/WebSearch/WebFetch calls to these tools when they
|
|
17
17
|
are cheaper, or just tells the agent what Smart Tool would do.
|
|
18
18
|
|
|
19
|
-
**Status:** 0.9.
|
|
19
|
+
**Status:** 0.9.33 beta. Windows only. What changed in each version: [CHANGELOG.md](CHANGELOG.md).
|
|
20
20
|
|
|
21
21
|
## How it works
|
|
22
22
|
|
|
@@ -92,7 +92,7 @@ updating, updated, update failed), and agent sessions get the same line on their
|
|
|
92
92
|
start when the SessionStart report is on, shown to you too) with the link to the changes. The tray menu has "Update to
|
|
93
93
|
X" (or "Check for updates") on demand and an "Update automatically" switch; `SMART_TOOL_AUTO_UPDATE=off` also turns
|
|
94
94
|
automatic updates off, and the newer-version notice then carries the command to run. Running the command by hand also updates; `~/.smart-tool` is kept. It downloads [uv](https://docs.astral.sh/uv/) (checksum
|
|
95
|
-
verified) and a private Python 3.12 into `~/.smart-tool/runtime`, with nothing added to `PATH`
|
|
95
|
+
verified) and a private Python 3.12 into `~/.smart-tool/runtime`, with nothing added to `PATH` and, in the registry, only two per-user (HKCU) keys that make the notification buttons work, then
|
|
96
96
|
copies Smart Tool to `%LOCALAPPDATA%\Programs\SmartTool` with its own virtual environment,
|
|
97
97
|
installs the web runtime, registers a per-user Scheduled Task that starts the tray at logon and starts the daemon on
|
|
98
98
|
`http://127.0.0.1:8765` (another free port is used, and announced, if 8765 is taken).
|
|
@@ -111,8 +111,18 @@ Redirects) and Maintenance (Setup audit, Startup).
|
|
|
111
111
|
URL ending where the API starts, like the OpenAI SDK `base_url` (`https://api.openai.com/v1`,
|
|
112
112
|
`https://openrouter.ai/api/v1`, `http://localhost:11434/v1`), and an optional API key. Saving tests `/models` first.
|
|
113
113
|
2. **Models**: embedding, rerank (optional; without it results use the hybrid order), the router model (small, no
|
|
114
|
-
reasoning: web search tier order and result checks), the index classification model (also reviews block patterns)
|
|
115
|
-
accepts works; the catalog is
|
|
114
|
+
reasoning: web search tier order and result checks), the index classification model (also reviews block patterns), the
|
|
115
|
+
scout model (the short answer of `smart_scout`) and the research model. Any id the gateway accepts works; the catalog is
|
|
116
|
+
shown as suggestions. Empty roles are filled with the recommended models the gateway offers.
|
|
117
|
+
|
|
118
|
+
When a new version recommends other models than the ones you use, a Windows notification (**Apply now**, **Don't
|
|
119
|
+
show again**) and the **New recommended models** card on this page offer them: nothing changes until you apply, the
|
|
120
|
+
models you had are kept, and **Switch back to my previous models** undoes it. The **Model failures (last 24 h)** table
|
|
121
|
+
lists every failed gateway call by role, model and tool; a model that fails half of its calls (5 or more) also raises a
|
|
122
|
+
notification and a tray alert. A failure inside a tool call also comes back as a warning line in the tool answer;
|
|
123
|
+
calls made by a hook or by background work (index scope and profile, block-pattern proposals) have no answer to
|
|
124
|
+
carry it, so they show only in the table, the log and the notification. The local embedder is not in that table or in
|
|
125
|
+
the usage meter: it is a sidecar on your machine with no price, and its state is the **Local models** section.
|
|
116
126
|
3. **Agents**: register the MCP server in Claude Code or Codex (runs their official `mcp add` command), install the
|
|
117
127
|
hook and choose its behavior under **Agent rules**. A hook written by an older Smart Tool shows as outdated, with
|
|
118
128
|
what an update changes; updating previews the entry and rewrites only Smart Tool's, after a backup.
|
|
@@ -177,11 +187,13 @@ Redirects) and Maintenance (Setup audit, Startup).
|
|
|
177
187
|
plus changed tests and tests using a pytest fixture whose `conftest.py` imports the changed code, ordered with the
|
|
178
188
|
likeliest failures first, and the command to run them. Changed files are read from disk, so new files and imports
|
|
179
189
|
count before the project is reindexed. `run_all` is true, with
|
|
180
|
-
the reason, when a configuration or lockfile changed or a changed code file is outside the import graph.
|
|
190
|
+
the reason, when a configuration or lockfile changed or a changed code file is outside the import graph. Go is read
|
|
191
|
+
by package: a file that imports a package depends on every file of it, so a changed package selects the tests of every
|
|
192
|
+
package that imports it (`go test ./pkg ...`); the Go and Rust documentation and duplicate hooks stay silent.
|
|
181
193
|
|
|
182
194
|
`project_manage` with `action=setup_audit` and a `project_root` (or `python setup_audit.py --project <folder>`)
|
|
183
|
-
reports what Claude Code and Codex actually load for that folder (settings layers, enabled plugins
|
|
184
|
-
servers, skills, CLAUDE.md and AGENTS.md) and where they collide: several hooks deciding on the same tool in
|
|
195
|
+
reports what Claude Code and Codex actually load for that folder (settings layers, enabled plugins of both,
|
|
196
|
+
hooks, MCP servers, skills, CLAUDE.md and AGENTS.md) and where they collide: several hooks deciding on the same tool in
|
|
185
197
|
parallel, hook scripts that do not exist or hide their errors, hooks calling an MCP server that is not configured,
|
|
186
198
|
MCP servers that do not start or offer the same tools twice (a gateway re-exposing a server also registered
|
|
187
199
|
directly), skills sharing a name or over the description budget, an AGENTS.md only Codex reads, broken `@imports`
|
|
@@ -251,13 +263,16 @@ fails to load is listed in the setup screen with its error and does not affect t
|
|
|
251
263
|
everything local.
|
|
252
264
|
- Web queries go to the search sources listed in `NOTICE`. Their free tiers are rate limited per IP address; respect
|
|
253
265
|
each service's terms of use.
|
|
254
|
-
- Logs and metrics are written to `~/.smart-tool/data` and never leave the machine.
|
|
266
|
+
- Logs and metrics are written to `~/.smart-tool/data` and never leave the machine. That includes the model failure log
|
|
267
|
+
(model, role, tool and the error text, up to 300 characters, which can carry the gateway's error body; never the request).
|
|
255
268
|
|
|
256
269
|
## Uninstall
|
|
257
270
|
|
|
258
271
|
Turn off "Start automatically" in the setup screen, quit from the tray, remove the hook entries from
|
|
259
272
|
`~/.claude/settings.json` / `~/.codex/hooks.json` (the setup screen shows them), run `claude mcp remove smart-tool -s
|
|
260
|
-
user` / `codex mcp remove smart-tool`, then delete `%LOCALAPPDATA%\Programs\SmartTool` and `~/.smart-tool`.
|
|
273
|
+
user` / `codex mcp remove smart-tool`, then delete `%LOCALAPPDATA%\Programs\SmartTool` and `~/.smart-tool`. Turning off "Start automatically" also removes the
|
|
274
|
+
two per-user registry keys of the notification buttons (`HKCU\Software\Classes\smart-tool-action` and
|
|
275
|
+
`HKCU\Software\Classes\AppUserModelId\SmartTool.Notifications`); they come back the next time a notification is shown.
|
|
261
276
|
|
|
262
277
|
## License
|
|
263
278
|
|
package/affected_tests.py
CHANGED
|
@@ -22,7 +22,7 @@ RUN_ALL = re.compile(
|
|
|
22
22
|
r"bun\.lockb?|pyproject\.toml|setup\.cfg|setup\.py|pytest\.ini|tox\.ini|requirements[^/]*\.txt|poetry\.lock|"
|
|
23
23
|
r"uv\.lock|Pipfile(\.lock)?|tsconfig[^/]*\.json|jsconfig\.json|(jest|vitest|vite|babel)\.config\.[^/]+|"
|
|
24
24
|
r"\.babelrc|\.mocharc[^/]*|pom\.xml|build\.gradle(\.kts)?|settings\.gradle(\.kts)?|gradle\.properties|"
|
|
25
|
-
r"\.env[^/]
|
|
25
|
+
r"\.env[^/]*|go\.(mod|sum|work(\.sum)?))$")
|
|
26
26
|
CI_CONFIG = re.compile(r"(^|/)(\.github|\.circleci|\.buildkite)/|(^|/)(\.gitlab-ci\.ya?ml|azure-pipelines\.ya?ml|"
|
|
27
27
|
r"Jenkinsfile|\.travis\.ya?ml|bitbucket-pipelines\.ya?ml|\.gitignore|\.gitattributes|"
|
|
28
28
|
r"\.editorconfig|\.mailmap|CODEOWNERS|LICENSE(\.[^/]*)?|\.npmignore)$")
|
|
@@ -31,7 +31,7 @@ NOTE = ("Selection from the indexed import graph: tests that load code through s
|
|
|
31
31
|
"suite before committing; run_all is true when a changed file can change every test (config, lockfile, "
|
|
32
32
|
"unanalyzed code). Changed files and files committed after the last indexing are read from disk, so new "
|
|
33
33
|
"files and imports count before reindexing; pytest fixtures link a test to the conftest.py that defines them.")
|
|
34
|
-
TEST_SOURCES = (".py", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts", ".java")
|
|
34
|
+
TEST_SOURCES = (".py", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts", ".java", ".go")
|
|
35
35
|
_PATH_LITERAL = re.compile(r"""['"`]((?:\.{0,2}/)?[\w.-]+(?:/[\w.-]+)*\.(?:py|js|mjs|cjs|ts|mts|cts|java))['"`]""")
|
|
36
36
|
_STRING_LITERAL = re.compile(r"""'([^'\n]*)'|"([^"\n]*)\"""")
|
|
37
37
|
_IMPORT_STATEMENT = re.compile(r"(?:^|;)\s*(?:import|from)\s+([A-Za-z_]\w*(?:\.\w+)*)")
|
|
@@ -132,6 +132,9 @@ def _commands(root, tests):
|
|
|
132
132
|
if runner is None and re.search(rf"\bnode\s+(\./)?{re.escape(local)}\b", scripts):
|
|
133
133
|
runner = "node-script"
|
|
134
134
|
groups[(folder or "", runner)].append(path)
|
|
135
|
+
elif lower.endswith(".go"):
|
|
136
|
+
folder, name = _nearest(root, path, ("go.mod",))
|
|
137
|
+
groups[(folder or "", "go" if name else None)].append(path)
|
|
135
138
|
elif lower.endswith(".java"):
|
|
136
139
|
folder, name = _nearest(root, path, ("pom.xml", "build.gradle", "build.gradle.kts"))
|
|
137
140
|
groups[(folder or "", "maven" if name == "pom.xml" else "gradle" if name else None)].append(path)
|
|
@@ -147,6 +150,8 @@ def _commands(root, tests):
|
|
|
147
150
|
if runner == "maven":
|
|
148
151
|
names = ",".join(sorted({os.path.basename(f).rsplit(".", 1)[0] for f in files}))
|
|
149
152
|
line = f"mvn test -Dtest={names} -Dsurefire.failIfNoSpecifiedTests=false"
|
|
153
|
+
elif runner == "go":
|
|
154
|
+
line = "go test " + " ".join(sorted({"./" + posixpath.dirname(f) if posixpath.dirname(f) else "." for f in local}))
|
|
150
155
|
elif runner == "node-script":
|
|
151
156
|
commands.extend({"cwd": folder or ".", "command": f"node {f}"} for f in sorted(local))
|
|
152
157
|
continue
|
|
@@ -260,6 +265,7 @@ _TEST_CASES = {
|
|
|
260
265
|
".py": re.compile(r"^\s*(async\s+)?def\s+test\w*\s*\(|^\s*class\s+Test\w*|unittest\.TestCase", re.M),
|
|
261
266
|
".java": re.compile(r"@(Test|ParameterizedTest|RepeatedTest|TestFactory|TestTemplate|RunWith|Suite)\b|"
|
|
262
267
|
r"\bstatic\s+\w*Test\s+suite\s*\(|\bextends\s+TestCase\b|\bvoid\s+test\w*\s*\("),
|
|
268
|
+
".go": re.compile(r"^func\s+(Test|Benchmark|Fuzz|Example)\w*\s*\(", re.M),
|
|
263
269
|
"script": re.compile(r"\b(it|test|describe|suite)(\.\w+)?\s*\(|\bDeno\.test\b|\bnode:test\b|\bassert\b"),
|
|
264
270
|
}
|
|
265
271
|
|
package/autostart.py
CHANGED
|
@@ -11,6 +11,8 @@ import subprocess
|
|
|
11
11
|
import sys
|
|
12
12
|
import tempfile
|
|
13
13
|
|
|
14
|
+
import toast_notice
|
|
15
|
+
|
|
14
16
|
TASK_NAME = os.environ.get("SMART_TOOL_TASK_NAME", "SmartTool-Tray")
|
|
15
17
|
|
|
16
18
|
|
|
@@ -188,6 +190,8 @@ def remove():
|
|
|
188
190
|
pass
|
|
189
191
|
if _remove_startup_shortcut():
|
|
190
192
|
removed.append("startup_folder")
|
|
193
|
+
if sys.platform == "win32" and toast_notice.unregister():
|
|
194
|
+
removed.append("toast_registry")
|
|
191
195
|
return {"ok": True, "removed": removed}
|
|
192
196
|
|
|
193
197
|
|
package/code_graph.py
CHANGED
|
@@ -20,10 +20,11 @@ import index_scope
|
|
|
20
20
|
import project_identity
|
|
21
21
|
import web_search_adapters
|
|
22
22
|
import web_document_graph
|
|
23
|
+
import code_graph_go
|
|
23
24
|
import document_text
|
|
24
25
|
|
|
25
|
-
VERSION =
|
|
26
|
-
SOURCE_EXTENSIONS = ('.py','.java','.js','.jsx','.ts','.tsx','.mjs','.cjs','.mts','.cts','.json','.html','.htm','.css','.scss','.sass','.less','.md','.markdown')
|
|
26
|
+
VERSION = 3
|
|
27
|
+
SOURCE_EXTENSIONS = ('.py','.go','.mod','.java','.js','.jsx','.ts','.tsx','.mjs','.cjs','.mts','.cts','.json','.html','.htm','.css','.scss','.sass','.less','.md','.markdown')
|
|
27
28
|
MAX_OVERLAY_BYTES = 5 * 1024 * 1024
|
|
28
29
|
MAX_FILES = 3000
|
|
29
30
|
MAX_TEXT = 24 * 1024 * 1024
|
|
@@ -284,7 +285,7 @@ def _analyze(path):
|
|
|
284
285
|
def _analyze_sources(files,sources,diagnostics,truncated):
|
|
285
286
|
merged={'symbols':[],'dependencies':[],'calls':[],'unresolved':[],'diagnostics':diagnostics,'parsed':[]}
|
|
286
287
|
retryable=False
|
|
287
|
-
for output in (_python_graph(sources),_javascript_graph(sources,[f['path'] for f in files]),_java_graph(sources),web_document_graph.analyze(sources,[f['path'] for f in files])):
|
|
288
|
+
for output in (_python_graph(sources),_javascript_graph(sources,[f['path'] for f in files]),_java_graph(sources),code_graph_go.analyze(sources),web_document_graph.analyze(sources,[f['path'] for f in files])):
|
|
288
289
|
for key in merged:merged[key].extend(output.get(key,[]))
|
|
289
290
|
truncated |= output.get('truncated',False)
|
|
290
291
|
retryable |= output.get('retryable',False)
|
package/code_graph_go.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Imports between Go files, read from the package clause and the import declarations of the indexed snapshot.
|
|
2
|
+
|
|
3
|
+
A Go package is a directory, so a file that imports a package depends on every non-test file of that directory, and an
|
|
4
|
+
internal test file (`package x` in `x_test.go`) depends on every non-test file of its own directory. Import paths are
|
|
5
|
+
resolved against the nearest `go.mod` module path; with no `go.mod` in the snapshot an import resolves to the longest
|
|
6
|
+
directory whose path ends the import path. No symbols or calls are produced: the doc and duplicate hooks stay silent
|
|
7
|
+
for Go until their thresholds are measured on it, and `affected_tests` selects by package import."""
|
|
8
|
+
import posixpath
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
_COMMENT = re.compile(r"//[^\n]*|/\*.*?\*/", re.S)
|
|
12
|
+
_PACKAGE = re.compile(r"^package\s+(\w+)", re.M)
|
|
13
|
+
_IMPORT_BLOCK = re.compile(r"^import\s*\((.*?)^\)", re.M | re.S)
|
|
14
|
+
_IMPORT_ONE = re.compile(r'^import\s+(?:[\w.]+\s+)?"([^"]+)"', re.M)
|
|
15
|
+
_IMPORT_SPEC = re.compile(r'^\s*(?:[\w.]+\s+)?"([^"]+)"', re.M)
|
|
16
|
+
_MODULE = re.compile(r"^module\s+(\S+)", re.M)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _module_roots(sources):
|
|
20
|
+
"""Folder of each go.mod in the snapshot -> its module path."""
|
|
21
|
+
roots = {}
|
|
22
|
+
for path, text in sources.items():
|
|
23
|
+
if posixpath.basename(path) == "go.mod":
|
|
24
|
+
match = _MODULE.search(_COMMENT.sub("", text))
|
|
25
|
+
if match:
|
|
26
|
+
roots[posixpath.dirname(path)] = match.group(1).strip('"')
|
|
27
|
+
return roots
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _enclosing_module(folder, roots):
|
|
31
|
+
"""(module folder, module path) of the nearest go.mod above `folder`, or (None, None)."""
|
|
32
|
+
while True:
|
|
33
|
+
if folder in roots:
|
|
34
|
+
return folder, roots[folder]
|
|
35
|
+
if not folder:
|
|
36
|
+
return None, None
|
|
37
|
+
folder = posixpath.dirname(folder)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _imports(text):
|
|
41
|
+
"""Import paths of one Go file (comments already blanked)."""
|
|
42
|
+
found = [match.group(1) for match in _IMPORT_ONE.finditer(text)]
|
|
43
|
+
for block in _IMPORT_BLOCK.finditer(text):
|
|
44
|
+
found.extend(spec.group(1) for spec in _IMPORT_SPEC.finditer(block.group(1)))
|
|
45
|
+
return found
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def analyze(sources):
|
|
49
|
+
go_files = {path: text for path, text in sources.items() if path.endswith(".go")}
|
|
50
|
+
parsed = [path for path in sources if path.endswith(".go") or posixpath.basename(path) == "go.mod"]
|
|
51
|
+
if not go_files:
|
|
52
|
+
return {"parsed": parsed}
|
|
53
|
+
roots = _module_roots(sources)
|
|
54
|
+
packages = {}
|
|
55
|
+
for path in go_files:
|
|
56
|
+
if not path.endswith("_test.go"):
|
|
57
|
+
packages.setdefault(posixpath.dirname(path), []).append(path)
|
|
58
|
+
|
|
59
|
+
def package_dir(path, spec):
|
|
60
|
+
folder, module = _enclosing_module(posixpath.dirname(path), roots)
|
|
61
|
+
if module is not None:
|
|
62
|
+
if spec != module and not spec.startswith(module + "/"):
|
|
63
|
+
return None
|
|
64
|
+
return folder if spec == module else posixpath.normpath(posixpath.join(folder, spec[len(module) + 1:]))
|
|
65
|
+
candidates = [d for d in packages if d and (spec == d or spec.endswith("/" + d))]
|
|
66
|
+
return max(candidates, key=len, default=None)
|
|
67
|
+
|
|
68
|
+
dependencies = []
|
|
69
|
+
for path, text in sorted(go_files.items()):
|
|
70
|
+
bare = _COMMENT.sub(lambda m: re.sub(r"[^\n]", " ", m.group(0)), text)
|
|
71
|
+
for spec in _imports(bare):
|
|
72
|
+
folder = package_dir(path, spec)
|
|
73
|
+
line = bare[:bare.find(f'"{spec}"')].count("\n") + 1
|
|
74
|
+
targets = [t for t in packages.get(folder, ()) if t != path] if folder is not None else []
|
|
75
|
+
if not targets:
|
|
76
|
+
dependencies.append({"source": path, "target": None, "specifier": spec, "line": line, "kind": "import",
|
|
77
|
+
"resolution": "external"})
|
|
78
|
+
dependencies.extend({"source": path, "target": target, "specifier": spec, "line": line, "kind": "import",
|
|
79
|
+
"resolution": "resolved"} for target in targets)
|
|
80
|
+
clause = _PACKAGE.search(bare)
|
|
81
|
+
if path.endswith("_test.go") and clause and not clause.group(1).endswith("_test"):
|
|
82
|
+
dependencies.extend({"source": path, "target": target, "specifier": posixpath.dirname(path) or ".",
|
|
83
|
+
"line": 1, "kind": "package", "resolution": "resolved"}
|
|
84
|
+
for target in packages.get(posixpath.dirname(path), ()))
|
|
85
|
+
return {"dependencies": dependencies, "parsed": parsed}
|
package/config.py
CHANGED
|
@@ -20,6 +20,9 @@ CONFIG_PATH = os.path.join(CONFIG_DIR, "config.json")
|
|
|
20
20
|
# RLock: update_config() chama load/save sob o mesmo lock; Lock travaria a própria thread.
|
|
21
21
|
_LOCK = threading.RLock()
|
|
22
22
|
|
|
23
|
+
# The config fields that select a gateway model; every per-role table (recommended models, failure log) keys on these.
|
|
24
|
+
MODEL_ROLES = ("router_model", "scope_model", "scout_model", "research_model", "embedding_model", "rerank_model")
|
|
25
|
+
|
|
23
26
|
DEFAULT_CONFIG = {
|
|
24
27
|
"router_model": "",
|
|
25
28
|
"scope_model": "",
|
package/index_profile.py
CHANGED
|
@@ -12,10 +12,12 @@ KINDS = ("code", "test", "doc")
|
|
|
12
12
|
RETRY_AFTER_S = 3600
|
|
13
13
|
MAX_DIRS = 90
|
|
14
14
|
PROSE = {"md", "txt", "docx", "rst", "adoc"}
|
|
15
|
-
# Data and markup inside a documentation folder are reports, not code (GitHub Linguist
|
|
15
|
+
# Data and markup inside a documentation, design or data-dump folder are reports and mockups, not code (GitHub Linguist
|
|
16
|
+
# treats docs/ as documentation; a design/ HTML or a probe's JSON result pushed hook_decision.py out of the code block).
|
|
16
17
|
DOC_DATA = {"html", "htm", "json", "csv", "xml", "yaml", "yml", "svg"}
|
|
17
18
|
_NAME_DOC = re.compile(r"(^|/)(readme|changelog|license|licence|contributing|install|history|authors)(\.[^/]*)?$", re.I)
|
|
18
19
|
_DIR_DOC = re.compile(r"(^|/)(docs?|adrs?|documentation)/", re.I)
|
|
20
|
+
_DIR_DOC_DATA = re.compile(r"(^|/)(docs?|adrs?|documentation|designs?|references?|mockups?|prototypes?|data|datasets?|reports?|results?)/", re.I)
|
|
19
21
|
_TEST = re.compile(r"(^|/)(tests?|__tests__|spec|specs|e2e|fixtures?|testdata|test-utils?)(/|$)|(^|/)test_[^/]+\.py$|"
|
|
20
22
|
r"(^|/)conftest\.py$|_test\.(py|go)$|\.(test|spec)(-d)?\.[cm]?[jt]sx?$|(^|/)[^/]*[a-z0-9](?-i:Tests?)\.java$|"
|
|
21
23
|
r"(^|/)src/test/|(^|/)test[-_][^/]*\.[cm]?[jt]sx?$|[-_]test\.[cm]?[jt]sx?$", re.I)
|
|
@@ -151,7 +153,7 @@ def kind(path, profile):
|
|
|
151
153
|
return _model_kind(path, groups or [{"path": ".", "kind": "doc"}])
|
|
152
154
|
if _TEST.search(path):
|
|
153
155
|
return "test"
|
|
154
|
-
if ext in DOC_DATA and
|
|
156
|
+
if ext in DOC_DATA and _DIR_DOC_DATA.search(path) and _explicit_kind(path, groups) != "code":
|
|
155
157
|
return "doc"
|
|
156
158
|
if not ext and _NAME_DOC.search(path):
|
|
157
159
|
return "doc"
|
package/model_client.py
CHANGED
|
@@ -7,6 +7,7 @@ import urllib.error
|
|
|
7
7
|
import usage_meter
|
|
8
8
|
|
|
9
9
|
import gateway
|
|
10
|
+
import model_health
|
|
10
11
|
|
|
11
12
|
OFFLINE = contextvars.ContextVar("model_offline", default=None)
|
|
12
13
|
_NO_TEMPERATURE = set()
|
|
@@ -34,6 +35,7 @@ def fetch(path, token, method="GET", body=None, timeout=15):
|
|
|
34
35
|
raise
|
|
35
36
|
finally:
|
|
36
37
|
usage_meter.record(path,body,result,headers,failure,time.monotonic()-started)
|
|
38
|
+
model_health.record(path,body,failure)
|
|
37
39
|
|
|
38
40
|
|
|
39
41
|
def _fetch(path, _token, method="GET", body=None, timeout=15):
|
package/model_defaults.py
CHANGED
|
@@ -11,7 +11,7 @@ import model_catalog
|
|
|
11
11
|
# first and price second (docs/research/2026-10-24-modelos-do-scout-e-dos-outros-papeis.md): the router answers pages
|
|
12
12
|
# and the scout reads code excerpts, where Claude Haiku 5.5 answered every request and led the no-index path; scope and
|
|
13
13
|
# research pick the GPT-5.4 nano, as accurate there at about half the price and without the long reasoning of the mini.
|
|
14
|
-
|
|
14
|
+
_CHOICES = {
|
|
15
15
|
"router_model": ("chat", "claude-haiku-5-5"),
|
|
16
16
|
"scope_model": ("chat", "gpt-5.4-nano"),
|
|
17
17
|
"scout_model": ("chat", "claude-haiku-5-5"),
|
|
@@ -19,10 +19,18 @@ RECOMMENDED_MODELS = {
|
|
|
19
19
|
"embedding_model": ("embedding", "text-embedding-3-small"),
|
|
20
20
|
"rerank_model": ("rerank", "rerank-3-5"),
|
|
21
21
|
}
|
|
22
|
+
RECOMMENDED_MODELS = {role: _CHOICES[role] for role in config.MODEL_ROLES}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _route_rank(model_id):
|
|
26
|
+
"""Vertex first (the owner's gateway is stable there), Bedrock only when nothing else serves the model."""
|
|
27
|
+
route = re.split(r"[/.:]", model_id)[0].lower()
|
|
28
|
+
return 0 if route.startswith("vertex") else 2 if route.startswith("bedrock") else 1
|
|
22
29
|
|
|
23
30
|
|
|
24
31
|
def _matching(catalog, name):
|
|
25
|
-
return next((model_id for model_id in sorted(catalog
|
|
32
|
+
return next((model_id for model_id in sorted(catalog, key=lambda found: (_route_rank(found), found))
|
|
33
|
+
if re.split(r"[/.:]", model_id)[-1] == name
|
|
26
34
|
or model_id.endswith(("/" + name, "." + name)) or model_id == name), None)
|
|
27
35
|
|
|
28
36
|
|
package/model_health.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Failure log and failure rate of the gateway models, per model and role (router, scope, scout, research, embedding,
|
|
2
|
+
rerank). Never stores request content: the model, the role, the tool that was running, the HTTP path, a short error
|
|
3
|
+
text and the time.
|
|
4
|
+
|
|
5
|
+
Three consumers read it: the tool answer (a warning line when a call of that very tool failed), the setup screen
|
|
6
|
+
(rate table and recent failures) and the tray (one toast per model per day when the rate passes the limit).
|
|
7
|
+
"""
|
|
8
|
+
import contextlib
|
|
9
|
+
import contextvars
|
|
10
|
+
import io
|
|
11
|
+
import os
|
|
12
|
+
import sqlite3
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
import config
|
|
16
|
+
import project_store
|
|
17
|
+
|
|
18
|
+
WINDOW_S = 24 * 3600
|
|
19
|
+
ALERT_MIN_CALLS = 5
|
|
20
|
+
ALERT_RATE = 0.5
|
|
21
|
+
ALERT_EVERY_S = 24 * 3600
|
|
22
|
+
MUTE_S = 7 * 24 * 3600
|
|
23
|
+
KEEP_S = 7 * 24 * 3600
|
|
24
|
+
KEEP_FAILURES = 500
|
|
25
|
+
ERROR_CHARS = 300
|
|
26
|
+
_PATHS = ("/v1/chat/completions", "/v1/embeddings", "/v1/rerank")
|
|
27
|
+
_TOOL = contextvars.ContextVar("model_health_tool", default=None)
|
|
28
|
+
_COLLECTED = contextvars.ContextVar("model_health_collected", default=None)
|
|
29
|
+
_SCHEMA = set()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@contextlib.contextmanager
|
|
33
|
+
def tool_call(name):
|
|
34
|
+
"""Marks the model calls made inside the block as run by tool `name` and collects their failures; the block yields
|
|
35
|
+
the list, which `warning_text` turns into the line appended to the tool answer."""
|
|
36
|
+
failures = []
|
|
37
|
+
tool_token, list_token = _TOOL.set(name), _COLLECTED.set(failures)
|
|
38
|
+
try:
|
|
39
|
+
yield failures
|
|
40
|
+
finally:
|
|
41
|
+
_TOOL.reset(tool_token)
|
|
42
|
+
_COLLECTED.reset(list_token)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _db():
|
|
46
|
+
return project_store._db()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _tables(db):
|
|
50
|
+
if project_store.DB_PATH in _SCHEMA and os.path.isfile(project_store.DB_PATH):
|
|
51
|
+
return
|
|
52
|
+
db.execute("CREATE TABLE IF NOT EXISTS model_calls(hour INTEGER,model TEXT,role TEXT,calls INTEGER,errors INTEGER,"
|
|
53
|
+
"PRIMARY KEY(hour,model,role))")
|
|
54
|
+
db.execute("CREATE TABLE IF NOT EXISTS model_failures(at REAL,model TEXT,role TEXT,tool TEXT,path TEXT,error TEXT)")
|
|
55
|
+
db.execute("CREATE TABLE IF NOT EXISTS model_alerts(model TEXT PRIMARY KEY,last_notified REAL,muted_until REAL)")
|
|
56
|
+
_SCHEMA.add(project_store.DB_PATH)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def role_of(model, cfg=None):
|
|
60
|
+
"""Name of the config field that selects `model` ('other' when none does)."""
|
|
61
|
+
cfg = cfg if cfg is not None else config.load_config()
|
|
62
|
+
return next((field for field in config.MODEL_ROLES if cfg.get(field) == model), "other")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def error_text(exc):
|
|
66
|
+
"""Short text of a failed call. An HTTP error body is read once and put back so the caller can still read it."""
|
|
67
|
+
text = f"{type(exc).__name__}: {exc}"
|
|
68
|
+
fp = getattr(exc, "fp", None)
|
|
69
|
+
if fp is not None and hasattr(exc, "read"):
|
|
70
|
+
try:
|
|
71
|
+
detail = exc.read()
|
|
72
|
+
exc.read = io.BytesIO(detail).read
|
|
73
|
+
text += " | " + detail.decode("utf-8", "replace")
|
|
74
|
+
except Exception:
|
|
75
|
+
pass
|
|
76
|
+
return " ".join(text.split())[:ERROR_CHARS]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def record(path, body, failure=None):
|
|
80
|
+
"""Counts one model call and, when it failed, logs it. Never raises: the log must not change the call's result."""
|
|
81
|
+
if path not in _PATHS:
|
|
82
|
+
return
|
|
83
|
+
try:
|
|
84
|
+
model = str((body or {}).get("model", "")) if isinstance(body, dict) else ""
|
|
85
|
+
role = role_of(model)
|
|
86
|
+
now = time.time()
|
|
87
|
+
text = error_text(failure) if failure is not None else None
|
|
88
|
+
with project_store._LOCK, _db() as db:
|
|
89
|
+
_tables(db)
|
|
90
|
+
db.execute("INSERT INTO model_calls VALUES(?,?,?,1,?) ON CONFLICT(hour,model,role) DO UPDATE SET "
|
|
91
|
+
"calls=calls+1,errors=errors+excluded.errors", (int(now // 3600), model, role, int(text is not None)))
|
|
92
|
+
if text is not None:
|
|
93
|
+
db.execute("INSERT INTO model_failures VALUES(?,?,?,?,?,?)", (now, model, role, _TOOL.get() or "", path, text))
|
|
94
|
+
db.execute("DELETE FROM model_failures WHERE at<? OR rowid NOT IN "
|
|
95
|
+
"(SELECT rowid FROM model_failures ORDER BY at DESC LIMIT ?)", (now - KEEP_S, KEEP_FAILURES))
|
|
96
|
+
db.execute("DELETE FROM model_calls WHERE hour<?", (int((now - KEEP_S) // 3600),))
|
|
97
|
+
collected = _COLLECTED.get()
|
|
98
|
+
if text is not None and collected is not None:
|
|
99
|
+
collected.append({"model": model, "role": role, "error": text})
|
|
100
|
+
except Exception:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def warning_text(failures, setup_url):
|
|
105
|
+
"""The line a tool answer carries when a model call inside it failed ('' when none did)."""
|
|
106
|
+
if not failures:
|
|
107
|
+
return ""
|
|
108
|
+
first = failures[0]
|
|
109
|
+
more = f" (+{len(failures) - 1} more)" if len(failures) > 1 else ""
|
|
110
|
+
return (f"\n\nSmart Tool model warning: {first['role']} `{first['model']}` failed in this call{more}: "
|
|
111
|
+
f"{first['error']}. Pick another model: {setup_url}/setup#models")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def summary(now=None):
|
|
115
|
+
"""Per model and role over the last 24 h, plus the 20 latest failures."""
|
|
116
|
+
now = now or time.time()
|
|
117
|
+
with project_store._LOCK, _db() as db:
|
|
118
|
+
_tables(db)
|
|
119
|
+
db.row_factory = sqlite3.Row
|
|
120
|
+
models = [dict(row) for row in db.execute(
|
|
121
|
+
"SELECT model,role,SUM(calls) AS calls,SUM(errors) AS errors FROM model_calls WHERE hour>=? "
|
|
122
|
+
"GROUP BY model,role ORDER BY SUM(errors) DESC,SUM(calls) DESC", (int((now - WINDOW_S) // 3600),))]
|
|
123
|
+
failures = [dict(row) for row in db.execute(
|
|
124
|
+
"SELECT at,model,role,tool,path,error FROM model_failures WHERE at>=? ORDER BY at DESC LIMIT 20", (now - WINDOW_S,))]
|
|
125
|
+
for row in models:
|
|
126
|
+
last = db.execute("SELECT error FROM model_failures WHERE model=? AND role=? ORDER BY at DESC LIMIT 1",
|
|
127
|
+
(row["model"], row["role"])).fetchone()
|
|
128
|
+
row["last_error"] = last[0] if last else ""
|
|
129
|
+
for row in models:
|
|
130
|
+
row["rate"] = round(row["errors"] / row["calls"], 3) if row["calls"] else 0.0
|
|
131
|
+
row["failing"] = row["calls"] >= ALERT_MIN_CALLS and row["rate"] >= ALERT_RATE
|
|
132
|
+
return {"window_hours": WINDOW_S // 3600, "models": models, "failures": failures}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def alerts_due(now=None):
|
|
136
|
+
"""Models over the failure limit that are neither muted nor already announced in the last 24 h; marks them
|
|
137
|
+
announced, so a second call in the same day returns nothing."""
|
|
138
|
+
now = now or time.time()
|
|
139
|
+
due = []
|
|
140
|
+
for row in summary(now)["models"]:
|
|
141
|
+
if not row["failing"]:
|
|
142
|
+
continue
|
|
143
|
+
with project_store._LOCK, _db() as db:
|
|
144
|
+
_tables(db)
|
|
145
|
+
seen = db.execute("SELECT last_notified,muted_until FROM model_alerts WHERE model=?", (row["model"],)).fetchone()
|
|
146
|
+
if seen and (seen[1] > now or now - seen[0] < ALERT_EVERY_S):
|
|
147
|
+
continue
|
|
148
|
+
db.execute("INSERT INTO model_alerts VALUES(?,?,0) ON CONFLICT(model) DO UPDATE SET last_notified=excluded.last_notified",
|
|
149
|
+
(row["model"], now))
|
|
150
|
+
due.append(row)
|
|
151
|
+
return due
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def mute(model, now=None):
|
|
155
|
+
"""Silences the failure toast for `model` for 7 days (the failures stay in the log)."""
|
|
156
|
+
if not model:
|
|
157
|
+
raise ValueError("No model to mute.")
|
|
158
|
+
now = now or time.time()
|
|
159
|
+
with project_store._LOCK, _db() as db:
|
|
160
|
+
_tables(db)
|
|
161
|
+
db.execute("INSERT INTO model_alerts VALUES(?,?,?) ON CONFLICT(model) DO UPDATE SET muted_until=excluded.muted_until",
|
|
162
|
+
(model, now, now + MUTE_S))
|
|
163
|
+
return f"failure alerts for {model} muted for 7 days"
|