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.
- snodo_validator_tool_jev-0.23.3/PKG-INFO +28 -0
- snodo_validator_tool_jev-0.23.3/README.md +19 -0
- snodo_validator_tool_jev-0.23.3/pyproject.toml +18 -0
- snodo_validator_tool_jev-0.23.3/setup.cfg +4 -0
- snodo_validator_tool_jev-0.23.3/src/snodo_validator_tool_jev/__init__.py +173 -0
- snodo_validator_tool_jev-0.23.3/src/snodo_validator_tool_jev.egg-info/PKG-INFO +28 -0
- snodo_validator_tool_jev-0.23.3/src/snodo_validator_tool_jev.egg-info/SOURCES.txt +10 -0
- snodo_validator_tool_jev-0.23.3/src/snodo_validator_tool_jev.egg-info/dependency_links.txt +1 -0
- snodo_validator_tool_jev-0.23.3/src/snodo_validator_tool_jev.egg-info/entry_points.txt +2 -0
- snodo_validator_tool_jev-0.23.3/src/snodo_validator_tool_jev.egg-info/requires.txt +2 -0
- snodo_validator_tool_jev-0.23.3/src/snodo_validator_tool_jev.egg-info/top_level.txt +1 -0
- snodo_validator_tool_jev-0.23.3/tests/test_decide.py +164 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
snodo_validator_tool_jev
|
|
@@ -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
|