wallras 0.20.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,8 @@
1
+ wallras_mcp.py,sha256=1DqCJkghltjsr38KxOggnycejuWF7-elR4_i_ixY-OI,17729
2
+ wallras_sdk.py,sha256=jtgkwjAwGU-uvTUMa3lxkMPgwRD5unQU_34Wan7sAxM,14863
3
+ wallras-0.20.1.dist-info/licenses/LICENSE,sha256=YLWvEOlXY8gW3uechPY87uVviR7gqkOxEWJn54CEYvA,1064
4
+ wallras-0.20.1.dist-info/METADATA,sha256=RaAPtmGn2XEpxpONkAMNrelscJX2aiy7hYlqECWCnSM,4007
5
+ wallras-0.20.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
6
+ wallras-0.20.1.dist-info/entry_points.txt,sha256=Y7mOHFUoiWaVyhfVENQnzHORfBtvJtfS6LoW1n0dIyI,49
7
+ wallras-0.20.1.dist-info/top_level.txt,sha256=E-S-v2M6NfWM_-3MPSCJU7-wh7ELhMtd_PRQpTZxZJo,24
8
+ wallras-0.20.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ wallras-mcp = wallras_mcp:main
@@ -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.
@@ -0,0 +1,2 @@
1
+ wallras_mcp
2
+ wallras_sdk
wallras_mcp.py ADDED
@@ -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()
wallras_sdk.py ADDED
@@ -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