snodo-validator-tool-jev 0.23.3__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.
@@ -0,0 +1,28 @@
1
+ Metadata-Version: 2.4
2
+ Name: snodo-validator-tool-jev
3
+ Version: 0.23.3
4
+ Summary: TypeSafe Jev decision tool for Snodo validators
5
+ Requires-Python: >=3.12
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: snodo-engine==0.23.3
8
+ Requires-Dist: httpx>=0.27.0
9
+
10
+ # Snodo TypeSafe Jev validator tool
11
+
12
+ An optional plugin that adds the `decide` function to opted-in LLM validators.
13
+ Jev returns typed answers and calibrated probabilities; the LLM remains
14
+ responsible for writing the validator verdict.
15
+
16
+ Install it into the same Python environment as Snodo and configure
17
+ `validator_tools: [{name: decide}]` on an LLM validator. The tool accepts
18
+ `context` as `diff`, `spec`, or `files` (with up to a small number of repository
19
+ relative file paths), and a list of named `questions` using `noul`, `choice`, or
20
+ `score` types. Choice criteria map option names to descriptions; score criteria
21
+ are ordered level labels.
22
+
23
+ Per-tool protocol options are `model` (default `jev-latest`), `api_key_env`
24
+ (default `TYPESAFE_API_KEY`), `base_url` (default `https://api.typesafe.ai`),
25
+ `timeout_seconds`, and `max_input_chars`. The key is read only from the named
26
+ environment variable. Each result line reports the typed answer and
27
+ probabilities; usage and any context truncation follow. Jev pricing is $0.042
28
+ per million input tokens; its maximum context length is unpublished.
@@ -0,0 +1,19 @@
1
+ # Snodo TypeSafe Jev validator tool
2
+
3
+ An optional plugin that adds the `decide` function to opted-in LLM validators.
4
+ Jev returns typed answers and calibrated probabilities; the LLM remains
5
+ responsible for writing the validator verdict.
6
+
7
+ Install it into the same Python environment as Snodo and configure
8
+ `validator_tools: [{name: decide}]` on an LLM validator. The tool accepts
9
+ `context` as `diff`, `spec`, or `files` (with up to a small number of repository
10
+ relative file paths), and a list of named `questions` using `noul`, `choice`, or
11
+ `score` types. Choice criteria map option names to descriptions; score criteria
12
+ are ordered level labels.
13
+
14
+ Per-tool protocol options are `model` (default `jev-latest`), `api_key_env`
15
+ (default `TYPESAFE_API_KEY`), `base_url` (default `https://api.typesafe.ai`),
16
+ `timeout_seconds`, and `max_input_chars`. The key is read only from the named
17
+ environment variable. Each result line reports the typed answer and
18
+ probabilities; usage and any context truncation follow. Jev pricing is $0.042
19
+ per million input tokens; its maximum context length is unpublished.
@@ -0,0 +1,18 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "snodo-validator-tool-jev"
7
+ version = "0.23.3"
8
+ description = "TypeSafe Jev decision tool for Snodo validators"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ dependencies = ["snodo-engine==0.23.3", "httpx>=0.27.0"]
12
+
13
+ [project.entry-points."snodo.validator_tools"]
14
+ decide = "snodo_validator_tool_jev:tool"
15
+
16
+ [tool.setuptools.packages.find]
17
+ where = ["src"]
18
+ namespaces = false
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,173 @@
1
+ """TypeSafe Jev decision tool for Snodo LLM validators."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from typing import Any, Mapping
8
+
9
+ import httpx
10
+
11
+ from snodo.validators.tool_registry import ValidatorTool, ValidatorToolContext
12
+
13
+ _MAX_FILES = 5
14
+
15
+ def handle_decide(arguments: Mapping[str, Any], context: ValidatorToolContext) -> str:
16
+ """Send validated questions and selected context to Jev; return safe text."""
17
+ key_name = str(context.tool_options.get("api_key_env", "TYPESAFE_API_KEY"))
18
+ api_key = os.environ.get(key_name)
19
+ if not api_key:
20
+ return f"TypeSafe Jev unavailable: environment variable {key_name} is not set."
21
+ try:
22
+ questions = _normalize_questions(arguments.get("questions"))
23
+ state = _get_context(arguments, context)
24
+ max_chars = max(1, int(context.tool_options.get("max_input_chars", 50000)))
25
+ truncated = len(state) > max_chars
26
+ state = state[:max_chars]
27
+ timeout = float(context.tool_options.get("timeout_seconds", 30))
28
+ base_url = str(context.tool_options.get("base_url", "https://api.typesafe.ai")).rstrip("/")
29
+ model = str(context.tool_options.get("model", "jev-latest"))
30
+ response = httpx.post(
31
+ f"{base_url}/v1/systemone",
32
+ headers={"Authorization": f"Bearer {api_key}"},
33
+ json={"model": model, "state": state, "questions": questions},
34
+ timeout=timeout,
35
+ )
36
+ response.raise_for_status()
37
+ payload = response.json()
38
+ if not isinstance(payload, dict) or not isinstance(payload.get("answers"), dict):
39
+ return "TypeSafe Jev error: malformed response; expected an answers object."
40
+ return _render(payload, questions, truncated)
41
+ except httpx.TimeoutException:
42
+ return "TypeSafe Jev error: request timed out. Check the endpoint or increase timeout_seconds."
43
+ except httpx.HTTPStatusError as exc:
44
+ return f"TypeSafe Jev error: HTTP {exc.response.status_code} from the decision API."
45
+ except httpx.HTTPError:
46
+ return "TypeSafe Jev error: could not complete the HTTP request. Check the endpoint and network."
47
+ except (ValueError, TypeError, OSError, json.JSONDecodeError) as exc:
48
+ # Do not include exception text: paths or HTTP internals must not leak secrets.
49
+ return f"TypeSafe Jev error: {type(exc).__name__}; check the tool arguments and options."
50
+
51
+
52
+ def _normalize_questions(raw: Any) -> dict[str, dict[str, Any]]:
53
+ if not isinstance(raw, list) or not raw:
54
+ raise ValueError("questions must be a non-empty list")
55
+ normalized: dict[str, dict[str, Any]] = {}
56
+ for question in raw:
57
+ if not isinstance(question, Mapping):
58
+ raise ValueError("each question must be an object")
59
+ name, kind, instructions = question.get("name"), question.get("type"), question.get("instructions")
60
+ if not all(isinstance(value, str) and value.strip() for value in (name, kind, instructions)):
61
+ raise ValueError("question name, type, and instructions are required")
62
+ if name in normalized or kind not in {"noul", "choice", "score"}:
63
+ raise ValueError("question names must be unique and types must be noul, choice, or score")
64
+ item: dict[str, Any] = {"type": kind, "instructions": instructions}
65
+ criteria = question.get("criteria")
66
+ if kind == "choice":
67
+ if not isinstance(criteria, Mapping) or not criteria:
68
+ raise ValueError("choice questions require criteria as an option-description map")
69
+ item["criteria"] = dict(criteria)
70
+ elif kind == "score":
71
+ if not isinstance(criteria, list) or not criteria:
72
+ raise ValueError("score questions require criteria as an ordered list of levels")
73
+ item["criteria"] = criteria
74
+ elif criteria is not None:
75
+ item["criteria"] = criteria
76
+ normalized[name] = item
77
+ return normalized
78
+
79
+
80
+ def _get_context(arguments: Mapping[str, Any], context: ValidatorToolContext) -> str:
81
+ selected = arguments.get("context")
82
+ if selected == "spec":
83
+ return context.spec_text
84
+ if selected == "diff":
85
+ if context.diff_accessor is None:
86
+ raise ValueError("task diff is unavailable")
87
+ if context.diff_refs:
88
+ return str(context.diff_accessor(*context.diff_refs))
89
+ return str(context.diff_accessor())
90
+ if selected != "files":
91
+ raise ValueError("context must be diff, spec, or files")
92
+ paths = arguments.get("files")
93
+ if not isinstance(paths, list) or not 1 <= len(paths) <= _MAX_FILES:
94
+ raise ValueError(f"files context requires between 1 and {_MAX_FILES} paths")
95
+ root = context.workspace_root.resolve()
96
+ contents = []
97
+ for value in paths:
98
+ if not isinstance(value, str) or not value:
99
+ raise ValueError("file paths must be non-empty strings")
100
+ target = (root / value).resolve()
101
+ if not target.is_relative_to(root) or not target.is_file():
102
+ raise ValueError(f"file path is outside the workspace or unreadable: {value}")
103
+ contents.append(f"--- {value} ---\n{target.read_text(encoding='utf-8')}")
104
+ return "\n".join(contents)
105
+
106
+
107
+ def _render(payload: Mapping[str, Any], questions: Mapping[str, Any], truncated: bool) -> str:
108
+ answers = payload["answers"]
109
+ lines = []
110
+ for name, question in questions.items():
111
+ answer = answers.get(name)
112
+ if not isinstance(answer, Mapping):
113
+ return "TypeSafe Jev error: malformed response; an answer is missing or invalid."
114
+ kind = question["type"]
115
+ if kind == "noul":
116
+ lines.append(f"{name} [noul]: {answer.get('noul')} (probability)")
117
+ elif kind == "choice":
118
+ lines.append(
119
+ f"{name} [choice]: {answer.get('choice')} (probabilities={_compact(answer.get('probabilities'))}; "
120
+ f"confidence={answer.get('confidence')})"
121
+ )
122
+ else:
123
+ lines.append(
124
+ f"{name} [score]: {answer.get('score')} (legend={_compact(answer.get('legend'))}; "
125
+ f"probabilities={_compact(answer.get('probabilities'))}; confidence={answer.get('confidence')})"
126
+ )
127
+ usage = payload.get("usage", {})
128
+ if not isinstance(usage, Mapping):
129
+ usage = {}
130
+ lines.append(f"usage: input_tokens={usage.get('input_tokens', usage.get('input', 'unknown'))}, "
131
+ f"output_tokens={usage.get('output_tokens', usage.get('output', 'unknown'))}")
132
+ if truncated:
133
+ lines.append("context truncated to max_input_chars")
134
+ return "\n".join(lines)
135
+
136
+
137
+ def _compact(value: Any) -> str:
138
+ return json.dumps(value, separators=(",", ":"), ensure_ascii=False)
139
+
140
+
141
+ tool = ValidatorTool(
142
+ definition={
143
+ "type": "function",
144
+ "function": {
145
+ "name": "decide",
146
+ "description": "Ask TypeSafe Jev typed questions about the task context.",
147
+ "parameters": {
148
+ "type": "object",
149
+ "properties": {
150
+ "context": {"type": "string", "enum": ["diff", "spec", "files"]},
151
+ "files": {"type": "array", "items": {"type": "string"}, "maxItems": _MAX_FILES},
152
+ "questions": {
153
+ "type": "array",
154
+ "items": {
155
+ "type": "object",
156
+ "properties": {
157
+ "name": {"type": "string"},
158
+ "type": {"type": "string", "enum": ["noul", "choice", "score"]},
159
+ "instructions": {"type": "string"},
160
+ "criteria": {"type": ["object", "array", "null"]},
161
+ },
162
+ "required": ["name", "type", "instructions"],
163
+ "additionalProperties": False,
164
+ },
165
+ },
166
+ },
167
+ "required": ["context", "questions"],
168
+ "additionalProperties": False,
169
+ },
170
+ },
171
+ },
172
+ handler=handle_decide,
173
+ )
@@ -0,0 +1,28 @@
1
+ Metadata-Version: 2.4
2
+ Name: snodo-validator-tool-jev
3
+ Version: 0.23.3
4
+ Summary: TypeSafe Jev decision tool for Snodo validators
5
+ Requires-Python: >=3.12
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: snodo-engine==0.23.3
8
+ Requires-Dist: httpx>=0.27.0
9
+
10
+ # Snodo TypeSafe Jev validator tool
11
+
12
+ An optional plugin that adds the `decide` function to opted-in LLM validators.
13
+ Jev returns typed answers and calibrated probabilities; the LLM remains
14
+ responsible for writing the validator verdict.
15
+
16
+ Install it into the same Python environment as Snodo and configure
17
+ `validator_tools: [{name: decide}]` on an LLM validator. The tool accepts
18
+ `context` as `diff`, `spec`, or `files` (with up to a small number of repository
19
+ relative file paths), and a list of named `questions` using `noul`, `choice`, or
20
+ `score` types. Choice criteria map option names to descriptions; score criteria
21
+ are ordered level labels.
22
+
23
+ Per-tool protocol options are `model` (default `jev-latest`), `api_key_env`
24
+ (default `TYPESAFE_API_KEY`), `base_url` (default `https://api.typesafe.ai`),
25
+ `timeout_seconds`, and `max_input_chars`. The key is read only from the named
26
+ environment variable. Each result line reports the typed answer and
27
+ probabilities; usage and any context truncation follow. Jev pricing is $0.042
28
+ per million input tokens; its maximum context length is unpublished.
@@ -0,0 +1,10 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/snodo_validator_tool_jev/__init__.py
4
+ src/snodo_validator_tool_jev.egg-info/PKG-INFO
5
+ src/snodo_validator_tool_jev.egg-info/SOURCES.txt
6
+ src/snodo_validator_tool_jev.egg-info/dependency_links.txt
7
+ src/snodo_validator_tool_jev.egg-info/entry_points.txt
8
+ src/snodo_validator_tool_jev.egg-info/requires.txt
9
+ src/snodo_validator_tool_jev.egg-info/top_level.txt
10
+ tests/test_decide.py
@@ -0,0 +1,2 @@
1
+ [snodo.validator_tools]
2
+ decide = snodo_validator_tool_jev:tool
@@ -0,0 +1,2 @@
1
+ snodo-engine==0.23.3
2
+ httpx>=0.27.0
@@ -0,0 +1,164 @@
1
+ from pathlib import Path
2
+ from types import SimpleNamespace
3
+
4
+ import httpx
5
+ import pytest
6
+
7
+ from snodo.validators.tool_registry import ValidatorToolContext, ValidatorToolRegistry
8
+ from snodo_validator_tool_jev import tool
9
+
10
+
11
+ @pytest.fixture
12
+ def context(tmp_path):
13
+ return ValidatorToolContext(
14
+ workspace_root=tmp_path,
15
+ spec_text="task spec",
16
+ validator_id="review",
17
+ tool_options={"max_input_chars": 100},
18
+ diff_refs=("base", "head"),
19
+ diff_accessor=lambda *refs: f"diff {refs}",
20
+ )
21
+
22
+
23
+ def test_discovery_request_and_render(monkeypatch, context):
24
+ plugin = SimpleNamespace(name="decide", load=lambda: tool)
25
+ monkeypatch.setattr("importlib.metadata.entry_points", lambda *, group: [plugin])
26
+ registry = ValidatorToolRegistry()
27
+ registry.discover_plugins()
28
+ registered = registry.lookup("decide")
29
+ assert registered is tool
30
+ assert registered.name == "decide"
31
+ monkeypatch.setenv("TYPESAFE_API_KEY", "secret-value")
32
+ captured = {}
33
+
34
+ class Response:
35
+ def raise_for_status(self):
36
+ return None
37
+
38
+ def json(self):
39
+ return {
40
+ "model": "jev-latest",
41
+ "answers": {
42
+ "safe": {"noul": 0.9},
43
+ "kind": {"choice": "good", "probabilities": {"good": 0.8}, "confidence": 0.8},
44
+ "quality": {"score": "high", "legend": ["low", "high"], "probabilities": [0.1, 0.9], "confidence": 0.9},
45
+ },
46
+ "usage": {"input_tokens": 11, "output_tokens": 4},
47
+ }
48
+
49
+ def post(url, **kwargs):
50
+ captured.update(url=url, **kwargs)
51
+ return Response()
52
+
53
+ monkeypatch.setattr("snodo_validator_tool_jev.httpx.post", post)
54
+ args = {
55
+ "context": "diff",
56
+ "questions": [
57
+ {"name": "safe", "type": "noul", "instructions": "Is safe?"},
58
+ {"name": "kind", "type": "choice", "instructions": "Choose", "criteria": {"good": "Good", "bad": None}},
59
+ {"name": "quality", "type": "score", "instructions": "Rate", "criteria": ["low", "high"]},
60
+ ],
61
+ }
62
+ result = registered.handler(args, context)
63
+ request = captured["json"]
64
+ assert captured["url"] == "https://api.typesafe.ai/v1/systemone"
65
+ assert captured["headers"]["Authorization"] == "Bearer secret-value"
66
+ assert request["state"] == "diff ('base', 'head')"
67
+ assert request["questions"]["safe"] == {"type": "noul", "instructions": "Is safe?"}
68
+ assert request["questions"]["kind"]["criteria"] == {"good": "Good", "bad": None}
69
+ assert request["questions"]["quality"]["criteria"] == ["low", "high"]
70
+ assert "safe [noul]: 0.9" in result
71
+ assert "kind [choice]: good" in result and "confidence=0.8" in result
72
+ assert "quality [score]: high" in result and "probabilities=[0.1,0.9]" in result
73
+ assert "input_tokens=11" in result and "output_tokens=4" in result
74
+ assert "secret-value" not in result
75
+
76
+
77
+ @pytest.mark.parametrize(
78
+ ("failure", "message"),
79
+ [
80
+ ("missing", "TYPESAFE_API_KEY is not set"),
81
+ ("http", "HTTP 503"),
82
+ ("timeout", "request timed out"),
83
+ ],
84
+ )
85
+ def test_readable_failures(monkeypatch, context, failure, message):
86
+ args = {"context": "spec", "questions": [{"name": "q", "type": "noul", "instructions": "Check"}]}
87
+ monkeypatch.delenv("TYPESAFE_API_KEY", raising=False)
88
+ if failure == "missing":
89
+ result = tool.handler(args, context)
90
+ else:
91
+ monkeypatch.setenv("TYPESAFE_API_KEY", "never-print-this")
92
+ if failure == "timeout":
93
+ def post(*args, **kwargs):
94
+ raise httpx.TimeoutException("never-print-this")
95
+ else:
96
+ class Response:
97
+ status_code = 503
98
+
99
+ def raise_for_status(self):
100
+ request = httpx.Request("POST", "https://example.test")
101
+ response = httpx.Response(503, request=request)
102
+ raise httpx.HTTPStatusError("failure", request=request, response=response)
103
+
104
+ def post(*args, **kwargs):
105
+ return Response()
106
+ monkeypatch.setattr("snodo_validator_tool_jev.httpx.post", post)
107
+ result = tool.handler(args, context)
108
+ assert message in result
109
+ assert "never-print-this" not in result
110
+
111
+
112
+ def test_truncates_context_and_confines_file_paths(monkeypatch, context, caplog):
113
+ monkeypatch.setenv("TYPESAFE_API_KEY", "secret-value")
114
+ context = ValidatorToolContext(context.workspace_root, "x" * 101, "review", {"max_input_chars": 10})
115
+ request_body = {}
116
+
117
+ class Response:
118
+ def raise_for_status(self):
119
+ return None
120
+
121
+ def json(self):
122
+ return {"answers": {"q": {"noul": 0.5}}, "usage": {}}
123
+
124
+ def post(url, **kwargs):
125
+ request_body.update(kwargs["json"])
126
+ return Response()
127
+
128
+ monkeypatch.setattr("snodo_validator_tool_jev.httpx.post", post)
129
+ args = {"context": "spec", "questions": [{"name": "q", "type": "noul", "instructions": "Check"}]}
130
+ result = tool.handler(args, context)
131
+ assert request_body["state"] == "x" * 10
132
+ assert "context truncated" in result
133
+ assert "secret-value" not in caplog.text
134
+
135
+ context = ValidatorToolContext(context.workspace_root, context.spec_text, "review", {"max_input_chars": 100})
136
+ path = context.workspace_root / "source.py"
137
+ path.write_text("safe", encoding="utf-8")
138
+ tool.handler(
139
+ {"context": "files", "files": ["source.py"], "questions": args["questions"]}, context
140
+ )
141
+ assert request_body["state"] == "--- source.py ---\nsafe"
142
+ escaped = Path("../outside.txt")
143
+ assert "ValueError" in tool.handler(
144
+ {"context": "files", "files": [str(escaped)], "questions": args["questions"]}, context
145
+ )
146
+
147
+
148
+ def test_malformed_response_is_returned_as_tool_text(monkeypatch, context):
149
+ monkeypatch.setenv("TYPESAFE_API_KEY", "secret-value")
150
+
151
+ class Response:
152
+ def raise_for_status(self):
153
+ return None
154
+
155
+ def json(self):
156
+ return {"answers": []}
157
+
158
+ monkeypatch.setattr("snodo_validator_tool_jev.httpx.post", lambda *args, **kwargs: Response())
159
+ result = tool.handler(
160
+ {"context": "spec", "questions": [{"name": "q", "type": "noul", "instructions": "Check"}]},
161
+ context,
162
+ )
163
+ assert "malformed response" in result
164
+ assert "secret-value" not in result