@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 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.31 beta. Windows only. What changed in each version: [CHANGELOG.md](CHANGELOG.md).
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` or the registry, then
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) and the research model. Any id the gateway
115
- accepts works; the catalog is shown as suggestions.
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, hooks, MCP
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 = 2
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)
@@ -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 treats docs/ as documentation).
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 _DIR_DOC.search(path) and _explicit_kind(path, groups) != "code":
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
- RECOMMENDED_MODELS = {
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) if re.split(r"[/.:]", model_id)[-1] == name
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
 
@@ -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"