wallras 0.20.1__tar.gz
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.
- wallras-0.20.1/LICENSE +21 -0
- wallras-0.20.1/PKG-INFO +86 -0
- wallras-0.20.1/README.md +69 -0
- wallras-0.20.1/pyproject.toml +26 -0
- wallras-0.20.1/setup.cfg +4 -0
- wallras-0.20.1/wallras.egg-info/PKG-INFO +86 -0
- wallras-0.20.1/wallras.egg-info/SOURCES.txt +10 -0
- wallras-0.20.1/wallras.egg-info/dependency_links.txt +1 -0
- wallras-0.20.1/wallras.egg-info/entry_points.txt +2 -0
- wallras-0.20.1/wallras.egg-info/top_level.txt +2 -0
- wallras-0.20.1/wallras_mcp.py +259 -0
- wallras-0.20.1/wallras_sdk.py +263 -0
wallras-0.20.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Wallras
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
wallras-0.20.1/PKG-INFO
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wallras
|
|
3
|
+
Version: 0.20.1
|
|
4
|
+
Summary: Clients for Wallras, the AI monitoring proxy: an MCP server for AI agents and a zero-dependency Python SDK
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://wallras.com
|
|
7
|
+
Project-URL: Documentation, https://wallras.com/docs
|
|
8
|
+
Project-URL: Issues, https://github.com/wallras/wallras-python/issues
|
|
9
|
+
Keywords: llm,monitoring,mcp,observability,drift,ai-agents
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Topic :: System :: Monitoring
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# Wallras clients for Python
|
|
19
|
+
|
|
20
|
+
Two single-file, zero-dependency clients for [Wallras](https://wallras.com), the AI monitoring proxy you run next to your app:
|
|
21
|
+
|
|
22
|
+
- **`wallras_mcp.py`**: an MCP server, so Claude Desktop, Claude Code, Cursor or your own agent can check drift, read requests and agent runs, work the review queue, fetch prompts and run regression sets. Read-only unless you pass `--enable-writes`.
|
|
23
|
+
- **`wallras_sdk.py`**: report what Wallras can't see on its own: a self-hosted model's token probabilities and decision records, agent steps (tool runs, lookups), and feedback; fetch prompt versions.
|
|
24
|
+
|
|
25
|
+
Both talk only to your own Wallras with your token. The Wallras engine itself ships as a Docker image (`ghcr.io/wallras/wallras-engine`); this repository holds the open-source clients.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install git+https://github.com/wallras/wallras-python
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Or run the MCP server without installing anything:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
uvx --from git+https://github.com/wallras/wallras-python wallras-mcp
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Each file also works on its own: copy it next to your code.
|
|
40
|
+
|
|
41
|
+
## MCP server
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{"mcpServers": {"wallras": {
|
|
45
|
+
"command": "uvx", "args": ["--from", "git+https://github.com/wallras/wallras-python", "wallras-mcp"],
|
|
46
|
+
"env": {"WALLRAS_URL": "http://localhost:8080", "WALLRAS_TOKEN": "a viewer or admin token"}}}}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Add `"--enable-writes"` to `args` for the tools that change something (feedback, review decisions, saving or promoting prompts, regression runs, optionally graded by a `judge_model`). Those need an admin token; regression runs call your provider with `WALLRAS_PROVIDER_KEY` (and `WALLRAS_ANTHROPIC_KEY` / `WALLRAS_GEMINI_KEY`), which stay on your machine.
|
|
50
|
+
|
|
51
|
+
## SDK
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from wallras_sdk import Wallras
|
|
55
|
+
w = Wallras("http://localhost:8080", token="your WALLRAS_APP_TOKEN")
|
|
56
|
+
w.report(model="llama-3.1-8b", prompt=prompt, answer=text,
|
|
57
|
+
tokens=Wallras.tokens_from_openai(response["choices"][0]["logprobs"]["content"]))
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Reports go in the background and never raise into your code. See the [docs](https://wallras.com/docs.html) for traces, decision records and prompts.
|
|
61
|
+
|
|
62
|
+
## Try a hub with two nodes
|
|
63
|
+
|
|
64
|
+
[`examples/docker-compose.hub.yml`](examples/docker-compose.hub.yml) starts one hub and two nodes on your machine:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
docker compose -f examples/docker-compose.hub.yml up -d
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Send requests to a node (`http://localhost:8081/v1` or `:8082`, with your provider key), then open `http://localhost:8080/wallras/ui` and the Nodes tab.
|
|
71
|
+
|
|
72
|
+
## Try active-active
|
|
73
|
+
|
|
74
|
+
[`examples/docker-compose.active.yml`](examples/docker-compose.active.yml) starts Postgres, three Wallras containers and a load balancer. Any container answers any request, and together they keep one tamper-evident record:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
docker compose -f examples/docker-compose.active.yml up -d
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Send requests to `http://localhost:8080/v1`, then ask the MCP server's `wallras_fleet` tool (or `GET /wallras/fleet`) which containers answered.
|
|
81
|
+
|
|
82
|
+
## Issues
|
|
83
|
+
|
|
84
|
+
Bugs and ideas for the clients, or for Wallras itself: [open an issue](https://github.com/wallras/wallras-python/issues). Security reports: see [SECURITY.md](https://wallras.com/security.html), not public issues.
|
|
85
|
+
|
|
86
|
+
MIT licensed. The files here are copies of the ones published with each Wallras release.
|
wallras-0.20.1/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Wallras clients for Python
|
|
2
|
+
|
|
3
|
+
Two single-file, zero-dependency clients for [Wallras](https://wallras.com), the AI monitoring proxy you run next to your app:
|
|
4
|
+
|
|
5
|
+
- **`wallras_mcp.py`**: an MCP server, so Claude Desktop, Claude Code, Cursor or your own agent can check drift, read requests and agent runs, work the review queue, fetch prompts and run regression sets. Read-only unless you pass `--enable-writes`.
|
|
6
|
+
- **`wallras_sdk.py`**: report what Wallras can't see on its own: a self-hosted model's token probabilities and decision records, agent steps (tool runs, lookups), and feedback; fetch prompt versions.
|
|
7
|
+
|
|
8
|
+
Both talk only to your own Wallras with your token. The Wallras engine itself ships as a Docker image (`ghcr.io/wallras/wallras-engine`); this repository holds the open-source clients.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install git+https://github.com/wallras/wallras-python
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Or run the MCP server without installing anything:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
uvx --from git+https://github.com/wallras/wallras-python wallras-mcp
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Each file also works on its own: copy it next to your code.
|
|
23
|
+
|
|
24
|
+
## MCP server
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{"mcpServers": {"wallras": {
|
|
28
|
+
"command": "uvx", "args": ["--from", "git+https://github.com/wallras/wallras-python", "wallras-mcp"],
|
|
29
|
+
"env": {"WALLRAS_URL": "http://localhost:8080", "WALLRAS_TOKEN": "a viewer or admin token"}}}}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Add `"--enable-writes"` to `args` for the tools that change something (feedback, review decisions, saving or promoting prompts, regression runs, optionally graded by a `judge_model`). Those need an admin token; regression runs call your provider with `WALLRAS_PROVIDER_KEY` (and `WALLRAS_ANTHROPIC_KEY` / `WALLRAS_GEMINI_KEY`), which stay on your machine.
|
|
33
|
+
|
|
34
|
+
## SDK
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from wallras_sdk import Wallras
|
|
38
|
+
w = Wallras("http://localhost:8080", token="your WALLRAS_APP_TOKEN")
|
|
39
|
+
w.report(model="llama-3.1-8b", prompt=prompt, answer=text,
|
|
40
|
+
tokens=Wallras.tokens_from_openai(response["choices"][0]["logprobs"]["content"]))
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Reports go in the background and never raise into your code. See the [docs](https://wallras.com/docs.html) for traces, decision records and prompts.
|
|
44
|
+
|
|
45
|
+
## Try a hub with two nodes
|
|
46
|
+
|
|
47
|
+
[`examples/docker-compose.hub.yml`](examples/docker-compose.hub.yml) starts one hub and two nodes on your machine:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
docker compose -f examples/docker-compose.hub.yml up -d
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Send requests to a node (`http://localhost:8081/v1` or `:8082`, with your provider key), then open `http://localhost:8080/wallras/ui` and the Nodes tab.
|
|
54
|
+
|
|
55
|
+
## Try active-active
|
|
56
|
+
|
|
57
|
+
[`examples/docker-compose.active.yml`](examples/docker-compose.active.yml) starts Postgres, three Wallras containers and a load balancer. Any container answers any request, and together they keep one tamper-evident record:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
docker compose -f examples/docker-compose.active.yml up -d
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Send requests to `http://localhost:8080/v1`, then ask the MCP server's `wallras_fleet` tool (or `GET /wallras/fleet`) which containers answered.
|
|
64
|
+
|
|
65
|
+
## Issues
|
|
66
|
+
|
|
67
|
+
Bugs and ideas for the clients, or for Wallras itself: [open an issue](https://github.com/wallras/wallras-python/issues). Security reports: see [SECURITY.md](https://wallras.com/security.html), not public issues.
|
|
68
|
+
|
|
69
|
+
MIT licensed. The files here are copies of the ones published with each Wallras release.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "wallras"
|
|
7
|
+
version = "0.20.1"
|
|
8
|
+
description = "Clients for Wallras, the AI monitoring proxy: an MCP server for AI agents and a zero-dependency Python SDK"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
dependencies = []
|
|
13
|
+
keywords = ["llm", "monitoring", "mcp", "observability", "drift", "ai-agents"]
|
|
14
|
+
classifiers = ["Programming Language :: Python :: 3", "Operating System :: OS Independent",
|
|
15
|
+
"Topic :: System :: Monitoring"]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
wallras-mcp = "wallras_mcp:main"
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://wallras.com"
|
|
22
|
+
Documentation = "https://wallras.com/docs"
|
|
23
|
+
Issues = "https://github.com/wallras/wallras-python/issues"
|
|
24
|
+
|
|
25
|
+
[tool.setuptools]
|
|
26
|
+
py-modules = ["wallras_mcp", "wallras_sdk"]
|
wallras-0.20.1/setup.cfg
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wallras
|
|
3
|
+
Version: 0.20.1
|
|
4
|
+
Summary: Clients for Wallras, the AI monitoring proxy: an MCP server for AI agents and a zero-dependency Python SDK
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://wallras.com
|
|
7
|
+
Project-URL: Documentation, https://wallras.com/docs
|
|
8
|
+
Project-URL: Issues, https://github.com/wallras/wallras-python/issues
|
|
9
|
+
Keywords: llm,monitoring,mcp,observability,drift,ai-agents
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Topic :: System :: Monitoring
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# Wallras clients for Python
|
|
19
|
+
|
|
20
|
+
Two single-file, zero-dependency clients for [Wallras](https://wallras.com), the AI monitoring proxy you run next to your app:
|
|
21
|
+
|
|
22
|
+
- **`wallras_mcp.py`**: an MCP server, so Claude Desktop, Claude Code, Cursor or your own agent can check drift, read requests and agent runs, work the review queue, fetch prompts and run regression sets. Read-only unless you pass `--enable-writes`.
|
|
23
|
+
- **`wallras_sdk.py`**: report what Wallras can't see on its own: a self-hosted model's token probabilities and decision records, agent steps (tool runs, lookups), and feedback; fetch prompt versions.
|
|
24
|
+
|
|
25
|
+
Both talk only to your own Wallras with your token. The Wallras engine itself ships as a Docker image (`ghcr.io/wallras/wallras-engine`); this repository holds the open-source clients.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install git+https://github.com/wallras/wallras-python
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Or run the MCP server without installing anything:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
uvx --from git+https://github.com/wallras/wallras-python wallras-mcp
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Each file also works on its own: copy it next to your code.
|
|
40
|
+
|
|
41
|
+
## MCP server
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{"mcpServers": {"wallras": {
|
|
45
|
+
"command": "uvx", "args": ["--from", "git+https://github.com/wallras/wallras-python", "wallras-mcp"],
|
|
46
|
+
"env": {"WALLRAS_URL": "http://localhost:8080", "WALLRAS_TOKEN": "a viewer or admin token"}}}}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Add `"--enable-writes"` to `args` for the tools that change something (feedback, review decisions, saving or promoting prompts, regression runs, optionally graded by a `judge_model`). Those need an admin token; regression runs call your provider with `WALLRAS_PROVIDER_KEY` (and `WALLRAS_ANTHROPIC_KEY` / `WALLRAS_GEMINI_KEY`), which stay on your machine.
|
|
50
|
+
|
|
51
|
+
## SDK
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from wallras_sdk import Wallras
|
|
55
|
+
w = Wallras("http://localhost:8080", token="your WALLRAS_APP_TOKEN")
|
|
56
|
+
w.report(model="llama-3.1-8b", prompt=prompt, answer=text,
|
|
57
|
+
tokens=Wallras.tokens_from_openai(response["choices"][0]["logprobs"]["content"]))
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Reports go in the background and never raise into your code. See the [docs](https://wallras.com/docs.html) for traces, decision records and prompts.
|
|
61
|
+
|
|
62
|
+
## Try a hub with two nodes
|
|
63
|
+
|
|
64
|
+
[`examples/docker-compose.hub.yml`](examples/docker-compose.hub.yml) starts one hub and two nodes on your machine:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
docker compose -f examples/docker-compose.hub.yml up -d
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Send requests to a node (`http://localhost:8081/v1` or `:8082`, with your provider key), then open `http://localhost:8080/wallras/ui` and the Nodes tab.
|
|
71
|
+
|
|
72
|
+
## Try active-active
|
|
73
|
+
|
|
74
|
+
[`examples/docker-compose.active.yml`](examples/docker-compose.active.yml) starts Postgres, three Wallras containers and a load balancer. Any container answers any request, and together they keep one tamper-evident record:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
docker compose -f examples/docker-compose.active.yml up -d
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Send requests to `http://localhost:8080/v1`, then ask the MCP server's `wallras_fleet` tool (or `GET /wallras/fleet`) which containers answered.
|
|
81
|
+
|
|
82
|
+
## Issues
|
|
83
|
+
|
|
84
|
+
Bugs and ideas for the clients, or for Wallras itself: [open an issue](https://github.com/wallras/wallras-python/issues). Security reports: see [SECURITY.md](https://wallras.com/security.html), not public issues.
|
|
85
|
+
|
|
86
|
+
MIT licensed. The files here are copies of the ones published with each Wallras release.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Wallras for AI agents: an MCP server, so assistants such as Claude, Cursor or your own agent can
|
|
2
|
+
check drift, read requests and traces, run regression sets, fetch prompts and work the review queue.
|
|
3
|
+
|
|
4
|
+
No dependencies (Python 3.9+). It talks to your own Wallras over HTTP with your token; nothing goes
|
|
5
|
+
anywhere else.
|
|
6
|
+
|
|
7
|
+
WALLRAS_URL=http://localhost:8080 WALLRAS_TOKEN=<a viewer or admin token> python wallras_mcp.py
|
|
8
|
+
python wallras_mcp.py --enable-writes # also the tools that change something (admin token)
|
|
9
|
+
|
|
10
|
+
In an MCP client configuration (Claude Desktop, Claude Code, Cursor...):
|
|
11
|
+
|
|
12
|
+
{"mcpServers": {"wallras": {"command": "python", "args": ["/path/to/wallras_mcp.py"],
|
|
13
|
+
"env": {"WALLRAS_URL": "http://localhost:8080", "WALLRAS_TOKEN": "..."}}}}
|
|
14
|
+
|
|
15
|
+
Read-only by default. Tools that change something (feedback, review decisions, saving or promoting
|
|
16
|
+
prompts, regression runs that call your provider) appear only with --enable-writes (or
|
|
17
|
+
WALLRAS_MCP_WRITE=1), and still need an admin token. WALLRAS_ADMIN_TOKEN works in place of WALLRAS_TOKEN. Regression runs and gated releases call your provider with WALLRAS_PROVIDER_KEY (and
|
|
18
|
+
WALLRAS_ANTHROPIC_KEY / WALLRAS_GEMINI_KEY for that traffic), which stay on this machine.
|
|
19
|
+
"""
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
import urllib.error
|
|
24
|
+
import urllib.parse
|
|
25
|
+
import urllib.request
|
|
26
|
+
|
|
27
|
+
VERSION = "0.20.1"
|
|
28
|
+
PROTOCOLS = ("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _schema(props=None, required=()):
|
|
32
|
+
return {"type": "object", "properties": props or {}, "required": list(required), "additionalProperties": False}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
S, N, B = {"type": "string"}, {"type": "number"}, {"type": "boolean"}
|
|
36
|
+
|
|
37
|
+
# name -> (description, input schema, method, path template, how arguments are sent, writes?)
|
|
38
|
+
TOOLS = {
|
|
39
|
+
"wallras_overview": ("What needs attention now: drift, failing agent runs, broken regression sets, review items, "
|
|
40
|
+
"each with the call to look closer.", _schema({"days": N}), "GET", "/wallras/overview", "query", False),
|
|
41
|
+
"wallras_judge": ("Live judge scores: the pass rate of real answers a judge model scored, per model and per prompt "
|
|
42
|
+
"version, and recent failures.", _schema({"days": N, "model": S}), "GET", "/wallras/judge", "query", False),
|
|
43
|
+
"wallras_fleet": ("Active-active: every Wallras container and worker on this database, its version, when it last "
|
|
44
|
+
"checked in and what it served; flags different versions running at once.", _schema({}), "GET",
|
|
45
|
+
"/wallras/fleet", "query", False),
|
|
46
|
+
"wallras_hub": ("On a hub: every node in one view: models drifting on several nodes at once, silent nodes, "
|
|
47
|
+
"each node's next actions, request totals.", _schema(), "GET", "/wallras/hub/overview", "query", False),
|
|
48
|
+
"wallras_hub_nodes": ("On a hub: each node's status, engine version, last report, usage and drift.", _schema(),
|
|
49
|
+
"GET", "/wallras/hub/nodes", "query", False),
|
|
50
|
+
"wallras_status": ("Mode, provider, models seen, silent model swaps and plan.", _schema(), "GET", "/wallras/status", "query", False),
|
|
51
|
+
"wallras_drift": ("Drift per model against its own baseline: answer length, wording, refusals, certainty, served model.",
|
|
52
|
+
_schema(), "GET", "/wallras/drift", "query", False),
|
|
53
|
+
"wallras_requests": ("The request log, newest first. Filter by model, app_name, status (ok or error) or text q.",
|
|
54
|
+
_schema({"model": S, "app_name": S, "status": {"type": "string", "enum": ["ok", "error"]},
|
|
55
|
+
"q": S, "hours": N, "limit": N}), "GET", "/wallras/requests", "query", False),
|
|
56
|
+
"wallras_request": ("One recorded request: question, answer, served model, confidence, feedback, consistency check.",
|
|
57
|
+
_schema({"event_id": S}, ["event_id"]), "GET", "/wallras/events/{event_id}", "path", False),
|
|
58
|
+
"wallras_usage": ("Requests, errors, tokens, cost and latency, by model or app.",
|
|
59
|
+
_schema({"hours": N, "by": {"type": "string", "enum": ["model", "app"]}}), "GET", "/wallras/usage",
|
|
60
|
+
"query", False),
|
|
61
|
+
"wallras_traces": ("Agent runs: steps, tool calls, tokens, cost and problems found (loops, tool errors).",
|
|
62
|
+
_schema({"days": N, "status": {"type": "string", "enum": ["ok", "check", "failed"]}, "model": S,
|
|
63
|
+
"limit": N}), "GET", "/wallras/traces", "query", False),
|
|
64
|
+
"wallras_trace": ("One agent run, step by step, with each answer, tool call and tool result.",
|
|
65
|
+
_schema({"trace_id": S}, ["trace_id"]), "GET", "/wallras/traces/{trace_id}", "path", False),
|
|
66
|
+
"wallras_consistency": ("How often answers change when the same question is asked again or reworded.",
|
|
67
|
+
_schema({"days": N}), "GET", "/wallras/consistency", "query", False),
|
|
68
|
+
"wallras_blind_spots": ("Topics and kinds of task where the AI is wrong more often than elsewhere.",
|
|
69
|
+
_schema({"days": N}), "GET", "/wallras/blind-spots", "query", False),
|
|
70
|
+
"wallras_datasets": ("Regression test sets and their last results.", _schema(), "GET", "/wallras/datasets", "query", False),
|
|
71
|
+
"wallras_dataset": ("One regression set: its items and run history.", _schema({"name": S}, ["name"]), "GET",
|
|
72
|
+
"/wallras/datasets/{name}", "path", False),
|
|
73
|
+
"wallras_experiments": ("Saved side-by-side comparisons of models or prompt versions.", _schema({"dataset": S}),
|
|
74
|
+
"GET", "/wallras/experiments", "query", False),
|
|
75
|
+
"wallras_prompts": ("Saved prompts with their labels (production, staging...) and latest version.", _schema(),
|
|
76
|
+
"GET", "/wallras/prompts", "query", False),
|
|
77
|
+
"wallras_prompt": ("One prompt: every version, who saved it, and the history of each label.",
|
|
78
|
+
_schema({"name": S}, ["name"]), "GET", "/wallras/prompts/{name}", "path", False),
|
|
79
|
+
"wallras_prompt_diff": ("What changed between two versions of a prompt.",
|
|
80
|
+
_schema({"name": S, "a": N, "b": N}, ["name", "a", "b"]), "GET",
|
|
81
|
+
"/wallras/prompts/{name}/diff", "path+query", False),
|
|
82
|
+
"wallras_prompt_usage": ("Live results per prompt version: requests, errors, latency, tokens, feedback accuracy.",
|
|
83
|
+
_schema({"name": S, "days": N}, ["name"]), "GET", "/wallras/prompts/{name}/usage",
|
|
84
|
+
"path+query", False),
|
|
85
|
+
"wallras_review_queue": ("Answers waiting for a person: regression close calls and answers that contradicted "
|
|
86
|
+
"themselves.", _schema({"status": {"type": "string", "enum": ["open", "decided", "all"]},
|
|
87
|
+
"kind": S}), "GET", "/wallras/review", "query", False),
|
|
88
|
+
# ---- changes (WALLRAS_MCP_WRITE=1)
|
|
89
|
+
"wallras_feedback": ("Record whether an answer was correct.",
|
|
90
|
+
_schema({"event_id": S, "correct": B, "note": S}, ["event_id", "correct"]), "POST",
|
|
91
|
+
"/wallras/feedback", "json", True),
|
|
92
|
+
"wallras_decide_review": ("Decide a review item: correct or wrong, optionally accepting the answer as the new "
|
|
93
|
+
"expected one.", _schema({"id": N, "verdict": {"type": "string", "enum": ["correct", "wrong"]},
|
|
94
|
+
"note": S, "accept_answer": B}, ["id", "verdict"]), "POST",
|
|
95
|
+
"/wallras/review/{id}", "path+json", True),
|
|
96
|
+
"wallras_run_dataset": ("Run a regression set on a model (calls your provider once per item) and get pass/fail, what "
|
|
97
|
+
"newly broke and what got fixed. Pass judge_model to have a separate model grade each answer (needed for sets "
|
|
98
|
+
"made with check \"judge\").",
|
|
99
|
+
_schema({"name": S, "model": S, "fail_below": N, "system": S, "judge_model": S,
|
|
100
|
+
"calibrate": B, "provider": {"type": "string", "enum": ["openai", "anthropic", "gemini"]}},
|
|
101
|
+
["name"]), "POST", "/wallras/datasets/{name}/run",
|
|
102
|
+
"path+json", True),
|
|
103
|
+
"wallras_save_prompt": ("Save a new prompt version (system text or messages, optional model and note).",
|
|
104
|
+
_schema({"name": S, "system": S, "messages": {"type": "array", "items": {"type": "object"}},
|
|
105
|
+
"model": S, "note": S}, ["name"]), "POST", "/wallras/prompts", "json", True),
|
|
106
|
+
"wallras_promote_prompt": ("Point a label at a prompt version. With gate_dataset, the version must pass that "
|
|
107
|
+
"regression set first, or nothing changes.",
|
|
108
|
+
_schema({"name": S, "version": N, "label": S, "gate_dataset": S, "fail_below": N, "reason": S, "gate_judge_model": S,
|
|
109
|
+
"gate_vars": {"type": "object", "additionalProperties": {"type": "string"}}},
|
|
110
|
+
["name", "version"]), "POST", "/wallras/prompts/{name}/promote", "path+json", True),
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _clean(args):
|
|
115
|
+
return {k: v for k, v in (args or {}).items() if v is not None}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class Server:
|
|
119
|
+
def __init__(self, env=None, out=None, enable_writes=False):
|
|
120
|
+
env = os.environ if env is None else env
|
|
121
|
+
self.url = (env.get("WALLRAS_URL") or "http://localhost:8080").rstrip("/")
|
|
122
|
+
if not self.url.lower().startswith(("http://", "https://")):
|
|
123
|
+
raise SystemExit("WALLRAS_URL must start with http:// or https://")
|
|
124
|
+
self.token = env.get("WALLRAS_TOKEN") or env.get("WALLRAS_ADMIN_TOKEN") or ""
|
|
125
|
+
self.write = enable_writes or env.get("WALLRAS_MCP_WRITE", "") in ("1", "true", "yes")
|
|
126
|
+
self.keys = {"authorization": f"Bearer {env['WALLRAS_PROVIDER_KEY']}" if env.get("WALLRAS_PROVIDER_KEY") else None,
|
|
127
|
+
"x-api-key": env.get("WALLRAS_ANTHROPIC_KEY"), "x-goog-api-key": env.get("WALLRAS_GEMINI_KEY")}
|
|
128
|
+
self.out = out or sys.stdout
|
|
129
|
+
|
|
130
|
+
def tools(self):
|
|
131
|
+
"""The most useful read tools first, then the rest, then (with --enable-writes) the changes."""
|
|
132
|
+
first = ["wallras_overview", "wallras_drift", "wallras_traces", "wallras_requests", "wallras_review_queue"]
|
|
133
|
+
order = first + [n for n, t in TOOLS.items() if n not in first and not t[5]] + \
|
|
134
|
+
[n for n, t in TOOLS.items() if t[5]]
|
|
135
|
+
return [{"name": n, "description": TOOLS[n][0], "inputSchema": TOOLS[n][1],
|
|
136
|
+
"annotations": {"readOnlyHint": not TOOLS[n][5], "destructiveHint": False, "openWorldHint": False}}
|
|
137
|
+
for n in order if self.write or not TOOLS[n][5]]
|
|
138
|
+
|
|
139
|
+
def _keys_for(self, args):
|
|
140
|
+
"""Only the keys a run needs: the named provider's (plus the OpenAI-style key when a judge model grades
|
|
141
|
+
the answers). With no provider named, a set can mix providers, so each configured key goes along; the
|
|
142
|
+
engine uses each only with its own provider."""
|
|
143
|
+
header = {"openai": "authorization", "anthropic": "x-api-key", "gemini": "x-goog-api-key"}
|
|
144
|
+
want = {header[args["provider"]]} if args.get("provider") in header else set(header.values())
|
|
145
|
+
if args.get("judge_model") or args.get("gate_judge_model"):
|
|
146
|
+
want.add("authorization")
|
|
147
|
+
return {k: v for k, v in self.keys.items() if v and k in want}
|
|
148
|
+
|
|
149
|
+
def _http(self, method, path, query=None, body=None, keys=None):
|
|
150
|
+
url = self.url + path + ("?" + urllib.parse.urlencode(query) if query else "")
|
|
151
|
+
headers = {"x-wallras-token": self.token, "accept": "application/json"}
|
|
152
|
+
headers.update(keys or {})
|
|
153
|
+
data = None
|
|
154
|
+
if body is not None:
|
|
155
|
+
data = json.dumps(body).encode()
|
|
156
|
+
headers["content-type"] = "application/json"
|
|
157
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
158
|
+
try:
|
|
159
|
+
# Reviewed: the URL is the configured http(s) Wallras address (checked in __init__).
|
|
160
|
+
with urllib.request.urlopen(req, timeout=180) as r: # nosemgrep # nosec B310
|
|
161
|
+
return r.status, json.loads(r.read() or b"{}")
|
|
162
|
+
except urllib.error.HTTPError as e:
|
|
163
|
+
try:
|
|
164
|
+
detail = json.loads(e.read() or b"{}")
|
|
165
|
+
except ValueError:
|
|
166
|
+
detail = {}
|
|
167
|
+
return e.code, detail
|
|
168
|
+
except Exception as exc:
|
|
169
|
+
return 0, {"detail": f"Wallras could not be reached at {self.url}: {type(exc).__name__}"}
|
|
170
|
+
|
|
171
|
+
def call(self, name, args):
|
|
172
|
+
if name not in TOOLS or (TOOLS[name][5] and not self.write):
|
|
173
|
+
return {"isError": True, "content": [{"type": "text", "text": f"Unknown tool {name}"}]}
|
|
174
|
+
_d, schema, method, path, how, _w = TOOLS[name]
|
|
175
|
+
args = _clean(args)
|
|
176
|
+
keys = self._keys_for(args) if name in ("wallras_run_dataset", "wallras_promote_prompt") else None
|
|
177
|
+
missing = [k for k in schema["required"] if k not in args]
|
|
178
|
+
if missing:
|
|
179
|
+
return {"isError": True, "content": [{"type": "text", "text": f"Missing: {', '.join(missing)}"}]}
|
|
180
|
+
unknown = [k for k in args if k not in schema["properties"]]
|
|
181
|
+
if unknown:
|
|
182
|
+
return {"isError": True, "content": [{"type": "text", "text": f"Unknown arguments: {', '.join(unknown)}"}]}
|
|
183
|
+
if name == "wallras_promote_prompt": # the friendly shape -> the API's
|
|
184
|
+
gate = {"dataset": args.pop("gate_dataset"), "fail_below": args.pop("fail_below", 1.0)} \
|
|
185
|
+
if "gate_dataset" in args else None
|
|
186
|
+
if gate and "gate_vars" in args:
|
|
187
|
+
gate["vars"] = args["gate_vars"]
|
|
188
|
+
if gate and "gate_judge_model" in args:
|
|
189
|
+
gate["judge_model"] = args["gate_judge_model"]
|
|
190
|
+
for k in ("fail_below", "gate_vars", "gate_judge_model"):
|
|
191
|
+
args.pop(k, None)
|
|
192
|
+
args = {**args, "label": args.get("label", "production"), **({"gate": gate} if gate else {})}
|
|
193
|
+
for k in [k for k in args if "{" + k + "}" in path]:
|
|
194
|
+
path = path.replace("{" + k + "}", urllib.parse.quote(str(args.pop(k)), safe=""))
|
|
195
|
+
if "id" in args and name == "wallras_decide_review":
|
|
196
|
+
args.pop("id")
|
|
197
|
+
query = args if how in ("query", "path+query") else None
|
|
198
|
+
body = args if how in ("json", "path+json") else None
|
|
199
|
+
status, data = self._http(method, path, query, body, keys=keys)
|
|
200
|
+
text = json.dumps(data, ensure_ascii=False, indent=1)
|
|
201
|
+
if len(text) > 60_000:
|
|
202
|
+
text = text[:60_000] + "\n... (cut; narrow the request with filters)"
|
|
203
|
+
if status == 200:
|
|
204
|
+
return {"content": [{"type": "text", "text": text}], "structuredContent": data if isinstance(data, dict) else {"result": data}}
|
|
205
|
+
why = data.get("detail") if isinstance(data, dict) else None
|
|
206
|
+
return {"isError": True, "content": [{"type": "text", "text": f"Wallras answered {status}: "
|
|
207
|
+
f"{why if isinstance(why, str) else json.dumps(why or data)[:2000]}"}]}
|
|
208
|
+
|
|
209
|
+
def handle(self, msg):
|
|
210
|
+
mid, method, params = msg.get("id"), msg.get("method"), msg.get("params") or {}
|
|
211
|
+
if method == "initialize":
|
|
212
|
+
asked = params.get("protocolVersion")
|
|
213
|
+
result = {"protocolVersion": asked if asked in PROTOCOLS else PROTOCOLS[0],
|
|
214
|
+
"capabilities": {"tools": {"listChanged": False}},
|
|
215
|
+
"serverInfo": {"name": "wallras", "title": "Wallras", "version": VERSION},
|
|
216
|
+
"instructions": "Start with wallras_overview: it says what needs attention and which tool "
|
|
217
|
+
"to call next. Answers and prompts can hold customer data; quote them only "
|
|
218
|
+
"as needed." + ("" if self.write else " This server is read-only.")}
|
|
219
|
+
elif method == "ping":
|
|
220
|
+
result = {}
|
|
221
|
+
elif method == "tools/list":
|
|
222
|
+
result = {"tools": self.tools()}
|
|
223
|
+
elif method == "tools/call":
|
|
224
|
+
result = self.call(params.get("name"), params.get("arguments"))
|
|
225
|
+
elif mid is None:
|
|
226
|
+
return None # a notification (initialized, cancelled...): nothing to answer
|
|
227
|
+
else:
|
|
228
|
+
return {"jsonrpc": "2.0", "id": mid, "error": {"code": -32601, "message": f"Method not found: {method}"}}
|
|
229
|
+
return None if mid is None else {"jsonrpc": "2.0", "id": mid, "result": result}
|
|
230
|
+
|
|
231
|
+
def serve(self, stdin=None):
|
|
232
|
+
for line in (stdin or sys.stdin):
|
|
233
|
+
line = line.strip()
|
|
234
|
+
if not line:
|
|
235
|
+
continue
|
|
236
|
+
try:
|
|
237
|
+
msg = json.loads(line)
|
|
238
|
+
except ValueError:
|
|
239
|
+
reply = {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "Parse error"}}
|
|
240
|
+
else:
|
|
241
|
+
reply = self.handle(msg) if isinstance(msg, dict) else \
|
|
242
|
+
{"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Invalid request"}}
|
|
243
|
+
if reply is not None:
|
|
244
|
+
self.out.write(json.dumps(reply, ensure_ascii=False) + "\n")
|
|
245
|
+
self.out.flush()
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def main():
|
|
249
|
+
"""The wallras-mcp command (pip install git+https://github.com/wallras/wallras-python, or
|
|
250
|
+
uvx --from git+https://github.com/wallras/wallras-python wallras-mcp)."""
|
|
251
|
+
unknown = [a for a in sys.argv[1:] if a not in ("--enable-writes", "--help", "-h")]
|
|
252
|
+
if unknown or any(a in ("--help", "-h") for a in sys.argv[1:]):
|
|
253
|
+
sys.stderr.write(__doc__)
|
|
254
|
+
sys.exit(2 if unknown else 0)
|
|
255
|
+
Server(enable_writes="--enable-writes" in sys.argv[1:]).serve()
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
if __name__ == "__main__":
|
|
259
|
+
main()
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Wallras SDK: send a self-hosted model's internal signals to Wallras (inside-out mode).
|
|
2
|
+
|
|
3
|
+
No dependencies. Reports are sent in the background by default, so they never slow your app,
|
|
4
|
+
and a failure to report never raises into your code.
|
|
5
|
+
|
|
6
|
+
from wallras_sdk import Wallras
|
|
7
|
+
w = Wallras("http://localhost:8080", token="your WALLRAS_APP_TOKEN")
|
|
8
|
+
|
|
9
|
+
# Hugging Face transformers
|
|
10
|
+
out = model.generate(**inputs, max_new_tokens=256, output_logits=True, output_scores=True,
|
|
11
|
+
return_dict_in_generate=True)
|
|
12
|
+
w.report_transformers(out, inputs["input_ids"].shape[1], tokenizer, model="llama-3.1-8b", prompt=prompt)
|
|
13
|
+
|
|
14
|
+
# Any runtime that returns OpenAI-style logprobs (vLLM, llama.cpp server, Ollama...)
|
|
15
|
+
w.report(model="llama-3.1-8b", prompt=prompt, answer=text,
|
|
16
|
+
tokens=Wallras.tokens_from_openai(response["choices"][0]["logprobs"]["content"]))
|
|
17
|
+
|
|
18
|
+
# Your own probe on hidden states (0 = the model doesn't believe it, 1 = it does)
|
|
19
|
+
w.report(model="...", answer=text, probe_score=0.31)
|
|
20
|
+
|
|
21
|
+
# A request that already went through the Wallras proxy: attach to it
|
|
22
|
+
w.report(event_id=resp.headers["X-Wallras-Event-Id"], tokens=tokens)
|
|
23
|
+
|
|
24
|
+
# Decision record: how a self-hosted model reached its answer, read from the network itself
|
|
25
|
+
out = model.generate(**inputs, max_new_tokens=128, return_dict_in_generate=True, output_logits=True,
|
|
26
|
+
output_hidden_states=True, output_attentions=True) # load with attn_implementation="eager"
|
|
27
|
+
w.explain_transformers(model, tokenizer, inputs, out, model_name="llama-3.1-8b", prompt=prompt)
|
|
28
|
+
|
|
29
|
+
# Agent traces: report steps Wallras can't see (tool runs, lookups, API calls) into the
|
|
30
|
+
# same run as your model calls (send X-Wallras-Trace: run_id on those calls too).
|
|
31
|
+
with w.step(run_id, "search_flights", kind="api"):
|
|
32
|
+
results = search(...) # timed; an exception is recorded as a failed step and re-raised
|
|
33
|
+
|
|
34
|
+
# Prompt management: fetch the version labelled production, with variables filled in. Send
|
|
35
|
+
# p["header"] with the model call so the request is filed under that version.
|
|
36
|
+
p = w.prompt("support-agent", customer="Asha", fallback={"messages": [...]})
|
|
37
|
+
client.chat.completions.create(model=p["model"] or "gpt-5-mini", messages=p["messages"],
|
|
38
|
+
extra_headers=p["header"])
|
|
39
|
+
"""
|
|
40
|
+
import contextlib
|
|
41
|
+
import json
|
|
42
|
+
import threading
|
|
43
|
+
import time
|
|
44
|
+
import urllib.request
|
|
45
|
+
|
|
46
|
+
__all__ = ["Wallras"]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Wallras:
|
|
50
|
+
def __init__(self, url="http://localhost:8080", token=None, timeout=5.0, background=True):
|
|
51
|
+
if not str(url).lower().startswith(("http://", "https://")):
|
|
52
|
+
raise ValueError("Wallras url must start with http:// or https://") # never file:// or other schemes
|
|
53
|
+
self.url = url.rstrip("/")
|
|
54
|
+
self.token = token
|
|
55
|
+
self.timeout = timeout
|
|
56
|
+
self.background = background
|
|
57
|
+
self._prompts = {}
|
|
58
|
+
|
|
59
|
+
# ------------------------------------------------------------ prompts
|
|
60
|
+
|
|
61
|
+
def prompt(self, name, label="production", version=None, cache_seconds=60, fallback=None, **variables):
|
|
62
|
+
"""The messages for a saved prompt, with variables filled in. Cached for cache_seconds. If Wallras
|
|
63
|
+
can't be reached, the last good copy is used, then `fallback` (a dict with "messages"), so an
|
|
64
|
+
outage never stops your app; with neither, it raises RuntimeError."""
|
|
65
|
+
q = {"label": label} if version is None else {"version": str(int(version))}
|
|
66
|
+
q.update({f"var.{k}": str(v) for k, v in variables.items()})
|
|
67
|
+
key = (name, tuple(sorted(q.items())))
|
|
68
|
+
hit = self._prompts.get(key)
|
|
69
|
+
if hit and time.time() - hit[0] < cache_seconds:
|
|
70
|
+
return hit[1]
|
|
71
|
+
path = f"/wallras/prompts/{urllib.request.quote(str(name), safe='')}/resolve?" + \
|
|
72
|
+
"&".join(f"{urllib.request.quote(k, safe='.')}={urllib.request.quote(v, safe='')}" for k, v in q.items())
|
|
73
|
+
req = urllib.request.Request(f"{self.url}{path}",
|
|
74
|
+
headers={**({"x-wallras-token": self.token} if self.token else {})})
|
|
75
|
+
try:
|
|
76
|
+
# Reviewed: only http(s) URLs reach here (checked in __init__).
|
|
77
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as r: # nosemgrep # nosec B310
|
|
78
|
+
got = json.loads(r.read())
|
|
79
|
+
self._prompts[key] = (time.time(), got)
|
|
80
|
+
return got
|
|
81
|
+
except Exception as exc:
|
|
82
|
+
if hit:
|
|
83
|
+
return hit[1]
|
|
84
|
+
if fallback is not None:
|
|
85
|
+
return {"name": name, "version": None, "model": None, "params": {}, "header": {}, **fallback}
|
|
86
|
+
raise RuntimeError(f"Wallras prompt {name!r} unavailable ({type(exc).__name__}) and no fallback given")
|
|
87
|
+
|
|
88
|
+
# ------------------------------------------------------------ converters
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def tokens_from_openai(content):
|
|
92
|
+
"""OpenAI-style `logprobs.content` list -> Wallras token list."""
|
|
93
|
+
out = []
|
|
94
|
+
for t in content or []:
|
|
95
|
+
if not isinstance(t, dict) or "logprob" not in t:
|
|
96
|
+
continue
|
|
97
|
+
alts = [x for x in t.get("top_logprobs") or [] if isinstance(x, dict) and "logprob" in x]
|
|
98
|
+
out.append({"logprob": float(t["logprob"]), "text": t.get("token", ""),
|
|
99
|
+
"top": [float(x["logprob"]) for x in alts],
|
|
100
|
+
"alts": [[x.get("token", ""), float(x["logprob"])] for x in alts]})
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def tokens_from_steps(step_logprobs, chosen_ids, top_k=5):
|
|
105
|
+
"""Per generated token: the full log-probability vector (any sequence of floats) and the
|
|
106
|
+
id that was chosen. Pure Python, so it works with lists from any framework."""
|
|
107
|
+
out = []
|
|
108
|
+
for row, tok in zip(step_logprobs, chosen_ids):
|
|
109
|
+
row = list(row)
|
|
110
|
+
top = sorted(row, reverse=True)[:top_k]
|
|
111
|
+
out.append({"logprob": float(row[int(tok)]), "top": [float(x) for x in top]})
|
|
112
|
+
return out
|
|
113
|
+
|
|
114
|
+
def report_transformers(self, generate_output, prompt_length, tokenizer=None, top_k=5, **kw):
|
|
115
|
+
"""`model.generate(..., output_logits=True, return_dict_in_generate=True)` output, for
|
|
116
|
+
decoder-only models, first sequence of the batch.
|
|
117
|
+
|
|
118
|
+
Uses the raw logits when present (transformers 4.38+). `scores` are reshaped by sampling
|
|
119
|
+
settings: temperature, top-k and top-p all change them, which would misstate how sure the
|
|
120
|
+
model really was. Without raw logits, report with greedy decoding for accurate numbers."""
|
|
121
|
+
seq = generate_output.sequences[0][prompt_length:]
|
|
122
|
+
steps = getattr(generate_output, "logits", None) or generate_output.scores
|
|
123
|
+
tokens = []
|
|
124
|
+
for step, tok in zip(steps, seq):
|
|
125
|
+
lp = step[0].float().log_softmax(-1)
|
|
126
|
+
tokens.append({"logprob": float(lp[int(tok)]), "top": [float(x) for x in lp.topk(top_k).values.tolist()]})
|
|
127
|
+
if tokenizer is not None and "answer" not in kw:
|
|
128
|
+
kw["answer"] = tokenizer.decode(seq, skip_special_tokens=True)
|
|
129
|
+
return self.report(tokens=tokens, **kw)
|
|
130
|
+
|
|
131
|
+
def explain_transformers(self, model, tokenizer, inputs, generate_output, *, model_name=None,
|
|
132
|
+
prompt=None, event_id=None, top_k=5, layers=True, attention=True, **kw):
|
|
133
|
+
"""Read how a Hugging Face model reached its answer and send a decision record.
|
|
134
|
+
|
|
135
|
+
Call model.generate(..., return_dict_in_generate=True, output_logits=True,
|
|
136
|
+
output_hidden_states=True, output_attentions=True). Attention weights need the model
|
|
137
|
+
loaded with attn_implementation="eager". Decoder-only models, first sequence of the batch.
|
|
138
|
+
Per generated token it records: the token, its probability and the top alternatives
|
|
139
|
+
(from raw logits, so sampling settings don't distort them), the layer from which the
|
|
140
|
+
network kept it as its top choice (logit lens: each layer's hidden state through the
|
|
141
|
+
final norm and output head), and the prompt tokens the last layer attended to most."""
|
|
142
|
+
import torch # only needed here
|
|
143
|
+
ids = inputs["input_ids"]
|
|
144
|
+
plen = ids.shape[1]
|
|
145
|
+
seq = generate_output.sequences[0][plen:]
|
|
146
|
+
steps = getattr(generate_output, "logits", None) or generate_output.scores
|
|
147
|
+
hidden = getattr(generate_output, "hidden_states", None) if layers else None
|
|
148
|
+
attns = getattr(generate_output, "attentions", None) if attention else None
|
|
149
|
+
head = model.get_output_embeddings()
|
|
150
|
+
norm = _final_norm(model)
|
|
151
|
+
dec = lambda t: tokenizer.decode([int(t)])
|
|
152
|
+
tokens, n_layers = [], None
|
|
153
|
+
if not bool(torch.isfinite(steps[0][0]).any()):
|
|
154
|
+
# Usually a half-precision model running on CPU: load it with torch_dtype=torch.float32.
|
|
155
|
+
return {"recorded": False, "error": "the model's outputs are NaN or infinite; nothing to explain"}
|
|
156
|
+
with torch.no_grad():
|
|
157
|
+
for i, (step, tok) in enumerate(zip(steps, seq)):
|
|
158
|
+
tok = int(tok)
|
|
159
|
+
lp = step[0].float().log_softmax(-1)
|
|
160
|
+
top = lp.topk(top_k)
|
|
161
|
+
rec = {"text": dec(tok), "logprob": _finite(lp[tok]),
|
|
162
|
+
"top": [v for v in (_finite(x) for x in top.values.tolist()) if v is not None],
|
|
163
|
+
"alts": [[dec(j), _finite(v)] for v, j in zip(top.values.tolist(), top.indices.tolist())
|
|
164
|
+
if _finite(v) is not None]}
|
|
165
|
+
if hidden is not None and i < len(hidden) and head is not None:
|
|
166
|
+
states = hidden[i][1:] # skip the embedding layer
|
|
167
|
+
n_layers = len(states)
|
|
168
|
+
picks = []
|
|
169
|
+
for li, h in enumerate(states):
|
|
170
|
+
x = h[0, -1]
|
|
171
|
+
if norm is not None and li < n_layers - 1: # the last layer is already normed
|
|
172
|
+
x = norm(x)
|
|
173
|
+
picks.append(int(head(x.to(head.weight.dtype)).argmax()))
|
|
174
|
+
settle = n_layers
|
|
175
|
+
for li in range(n_layers - 1, -1, -1):
|
|
176
|
+
if picks[li] != tok:
|
|
177
|
+
break
|
|
178
|
+
settle = li + 1
|
|
179
|
+
rec["layer"] = settle if picks[-1] == tok else n_layers
|
|
180
|
+
if attns is not None and i < len(attns) and attns[i]:
|
|
181
|
+
a = attns[i][-1][0].float().mean(0)[-1][:plen] # last layer, heads averaged
|
|
182
|
+
if bool(torch.isfinite(a).all()) and a.sum() > 0:
|
|
183
|
+
a = a / a.sum()
|
|
184
|
+
w, j = a.topk(min(3, plen))
|
|
185
|
+
rec["attn"] = [[int(x), float(y)] for x, y in zip(j.tolist(), w.tolist())]
|
|
186
|
+
tokens.append(rec)
|
|
187
|
+
prompt_tokens = [dec(t) for t in ids[0].tolist()] if attns is not None else None
|
|
188
|
+
answer = tokenizer.decode(seq, skip_special_tokens=True)
|
|
189
|
+
return self.report(tokens=tokens, model=model_name, answer=kw.pop("answer", answer),
|
|
190
|
+
prompt=prompt, event_id=event_id, prompt_tokens=prompt_tokens,
|
|
191
|
+
layers=n_layers, **kw)
|
|
192
|
+
|
|
193
|
+
# ------------------------------------------------------------ sending
|
|
194
|
+
|
|
195
|
+
def report(self, *, model=None, answer=None, prompt=None, event_id=None, tokens=None,
|
|
196
|
+
probe_score=None, topic=None, prompt_tokens=None, layers=None, reasoning=None):
|
|
197
|
+
body = {k: v for k, v in {"model": model, "answer": answer, "prompt": prompt, "event_id": event_id,
|
|
198
|
+
"tokens": tokens or [], "probe_score": probe_score, "topic": topic,
|
|
199
|
+
"prompt_tokens": prompt_tokens, "layers": layers,
|
|
200
|
+
"reasoning": reasoning}.items()
|
|
201
|
+
if v is not None}
|
|
202
|
+
if self.background:
|
|
203
|
+
threading.Thread(target=self._send, args=(body,), daemon=True).start()
|
|
204
|
+
return None
|
|
205
|
+
return self._send(body)
|
|
206
|
+
|
|
207
|
+
@contextlib.contextmanager
|
|
208
|
+
def step(self, trace_id, name, kind="tool"):
|
|
209
|
+
"""Time a step of an agent run and report it. Only the name, kind, timing, outcome and
|
|
210
|
+
error type are sent, never inputs or outputs."""
|
|
211
|
+
started, status, error = time.time(), "ok", None
|
|
212
|
+
try:
|
|
213
|
+
yield
|
|
214
|
+
except BaseException as exc:
|
|
215
|
+
status, error = "error", type(exc).__name__
|
|
216
|
+
raise
|
|
217
|
+
finally:
|
|
218
|
+
body = {"name": str(name)[:80], "kind": kind, "started": started, "status": status,
|
|
219
|
+
"duration_ms": round((time.time() - started) * 1000, 1),
|
|
220
|
+
**({"error": error} if error else {})}
|
|
221
|
+
path = f"/wallras/traces/{urllib.request.quote(str(trace_id), safe='')}/steps"
|
|
222
|
+
if self.background:
|
|
223
|
+
threading.Thread(target=self._send, args=(body, path), daemon=True).start()
|
|
224
|
+
else:
|
|
225
|
+
self._send(body, path)
|
|
226
|
+
|
|
227
|
+
def _send(self, body, path="/wallras/internals"):
|
|
228
|
+
try: # never send NaN or infinity: the server would reject the whole report
|
|
229
|
+
data = json.dumps(body, allow_nan=False).encode()
|
|
230
|
+
except ValueError:
|
|
231
|
+
return {"recorded": False, "error": "report contained NaN or infinite numbers"}
|
|
232
|
+
req = urllib.request.Request(f"{self.url}{path}", data=data,
|
|
233
|
+
headers={"content-type": "application/json",
|
|
234
|
+
**({"x-wallras-token": self.token} if self.token else {})})
|
|
235
|
+
try:
|
|
236
|
+
# Reviewed: only http(s) URLs reach here (checked in __init__).
|
|
237
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as r: # nosemgrep # nosec B310
|
|
238
|
+
return json.loads(r.read() or b"{}")
|
|
239
|
+
except Exception as exc: # monitoring must never break your app
|
|
240
|
+
return {"recorded": False, "error": type(exc).__name__}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _final_norm(model):
|
|
244
|
+
"""The norm applied before the output head, for the logit lens. None if not found."""
|
|
245
|
+
for path in ("model.norm", "transformer.ln_f", "gpt_neox.final_layer_norm", "model.decoder.final_layer_norm",
|
|
246
|
+
"transformer.norm_f", "backbone.norm_f"):
|
|
247
|
+
obj = model
|
|
248
|
+
for part in path.split("."):
|
|
249
|
+
obj = getattr(obj, part, None)
|
|
250
|
+
if obj is None:
|
|
251
|
+
break
|
|
252
|
+
if obj is not None:
|
|
253
|
+
return obj
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _finite(x):
|
|
258
|
+
"""A plain float, or None for NaN and infinity (from broken or half-precision models)."""
|
|
259
|
+
try:
|
|
260
|
+
f = float(x)
|
|
261
|
+
except (TypeError, ValueError):
|
|
262
|
+
return None
|
|
263
|
+
return f if f == f and f not in (float("inf"), float("-inf")) else None
|