judgetap 0.2.1.dev40001__tar.gz → 0.2.1.dev42001__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.
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/PKG-INFO +26 -1
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/README.md +25 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/docs/SPEC.md +2 -1
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/pyproject.toml +1 -1
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/__init__.py +1 -1
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/engines/__init__.py +5 -1
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/engines/julia.py +29 -10
- judgetap-0.2.1.dev42001/src/judgetap/engines/llm.py +274 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/guard/install.py +3 -1
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_engine_julia.py +30 -0
- judgetap-0.2.1.dev42001/tests/test_llm_logprobs.py +266 -0
- judgetap-0.2.1.dev40001/src/judgetap/engines/llm.py +0 -103
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/.github/workflows/ci.yml +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/.github/workflows/demo.yml +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/.github/workflows/release.yml +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/.gitignore +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/.python-version +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/.release-please-manifest.json +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/CHANGELOG.md +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/CONTRIBUTING.md +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/LICENSE +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/docs/demo.tape +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/release-please-config.json +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/_compat.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/api.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/cascade.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/cli.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/dashboard/__init__.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/dashboard/data.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/dashboard/page.html +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/dashboard/server.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/decision_log.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/engine.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/engines/agentjev.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/engines/jev.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/engines/laya.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/errors.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/evaluate.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/guard/__init__.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/guard/core.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/guard/hook.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/guard/loop.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/guard/rules.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/guard/stop.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/py.typed +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/secrets.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/testing.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/types.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_api.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_call_accounting.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_calls.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_cascade.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_dashboard.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_decision_log.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_engine_jev.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_engine_llm.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_engine_local.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_evaluate.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_guard_agents.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_guard_core.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_guard_hook.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_guard_loop.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_guard_polish_76.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_guard_rules.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_guard_stop.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_guard_trust.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_questions.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_robustness_68.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_secrets.py +0 -0
- {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: judgetap
|
|
3
|
-
Version: 0.2.1.
|
|
3
|
+
Version: 0.2.1.dev42001
|
|
4
4
|
Summary: Fast typed decisions (choice, score, yes/no) across Jev-style engines, plus a guard for coding agents. Early development.
|
|
5
5
|
Project-URL: Homepage, https://github.com/mergesafe-ai/judgetap
|
|
6
6
|
Author-email: Omer Bar-Ness <omer@zsquared.io>
|
|
@@ -101,6 +101,31 @@ Every PR here is reviewed by [MergeSafe](https://mergesafe.ai) before merge. A f
|
|
|
101
101
|
|
|
102
102
|
Early development. Design in [docs/SPEC.md](docs/SPEC.md); roadmap in the issues.
|
|
103
103
|
|
|
104
|
+
## Open judge models
|
|
105
|
+
|
|
106
|
+
Open models now match Jev on most decision tasks. LangWatch's comparison reports, over the tasks each model could run: Jev 81.9%, Eikos-27B 80.0%, Shisa DE-1 79.7%, AutoJev-27B 78.9% ([langwatch.ai/compare/jev-vs-all](https://langwatch.ai/compare/jev-vs-all); their numbers, not ours). Run one behind any OpenAI-compatible server and use it through the `llm` engine in **logprobs** mode, which reads each option's probability from the model's token logprobs instead of asking for JSON:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
# vLLM (GPU; a 27B model needs roughly an H100):
|
|
110
|
+
vllm serve <hf-repo-of-the-model> --served-model-name judge
|
|
111
|
+
# or Ollama:
|
|
112
|
+
ollama serve # after `ollama pull <model>`
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
import os
|
|
117
|
+
|
|
118
|
+
import judgetap as jt
|
|
119
|
+
|
|
120
|
+
os.environ["OPENAI_API_BASE"] = (
|
|
121
|
+
"http://127.0.0.1:8000/v1" # vLLM; Ollama: http://127.0.0.1:11434/v1
|
|
122
|
+
)
|
|
123
|
+
os.environ["OPENAI_API_KEY"] = "local" # any value for a local server
|
|
124
|
+
jt.configure(jt.engines.load("llm:openai/judge?logprobs"))
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Logprobs mode makes one short call per question (one generated token), run concurrently (up to 8 at a time), and each call is counted in the metrics. The probabilities come from the model's token distribution; they are not calibrated (answers stay `calibrated=False`), so use `judgetap eval` to check how well they track accuracy on your cases. If the server doesn't support logprobs, the engine falls back to JSON mode on its own.
|
|
128
|
+
|
|
104
129
|
## Contributing
|
|
105
130
|
|
|
106
131
|
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
@@ -84,6 +84,31 @@ Every PR here is reviewed by [MergeSafe](https://mergesafe.ai) before merge. A f
|
|
|
84
84
|
|
|
85
85
|
Early development. Design in [docs/SPEC.md](docs/SPEC.md); roadmap in the issues.
|
|
86
86
|
|
|
87
|
+
## Open judge models
|
|
88
|
+
|
|
89
|
+
Open models now match Jev on most decision tasks. LangWatch's comparison reports, over the tasks each model could run: Jev 81.9%, Eikos-27B 80.0%, Shisa DE-1 79.7%, AutoJev-27B 78.9% ([langwatch.ai/compare/jev-vs-all](https://langwatch.ai/compare/jev-vs-all); their numbers, not ours). Run one behind any OpenAI-compatible server and use it through the `llm` engine in **logprobs** mode, which reads each option's probability from the model's token logprobs instead of asking for JSON:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
# vLLM (GPU; a 27B model needs roughly an H100):
|
|
93
|
+
vllm serve <hf-repo-of-the-model> --served-model-name judge
|
|
94
|
+
# or Ollama:
|
|
95
|
+
ollama serve # after `ollama pull <model>`
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
import os
|
|
100
|
+
|
|
101
|
+
import judgetap as jt
|
|
102
|
+
|
|
103
|
+
os.environ["OPENAI_API_BASE"] = (
|
|
104
|
+
"http://127.0.0.1:8000/v1" # vLLM; Ollama: http://127.0.0.1:11434/v1
|
|
105
|
+
)
|
|
106
|
+
os.environ["OPENAI_API_KEY"] = "local" # any value for a local server
|
|
107
|
+
jt.configure(jt.engines.load("llm:openai/judge?logprobs"))
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Logprobs mode makes one short call per question (one generated token), run concurrently (up to 8 at a time), and each call is counted in the metrics. The probabilities come from the model's token distribution; they are not calibrated (answers stay `calibrated=False`), so use `judgetap eval` to check how well they track accuracy on your cases. If the server doesn't support logprobs, the engine falls back to JSON mode on its own.
|
|
111
|
+
|
|
87
112
|
## Contributing
|
|
88
113
|
|
|
89
114
|
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
@@ -38,7 +38,8 @@ sj.batch([...questions], context=...) -> list[Decision] # one pa
|
|
|
38
38
|
| `laya` | local, open weights (Apache-2.0) | via transformers; GPU optional |
|
|
39
39
|
| `julia` | local, open weights (Apache-2.0), 144M | Julia-1 runtime from its model repo; CPU by default; Jev-shaped API; 2-20 options per question |
|
|
40
40
|
| `agentjev` | local, open weights | ~50 ms per pass |
|
|
41
|
-
| `llm` | any structured-output LLM via LiteLLM | OpenAI, Gemini, Anthropic, Ollama; probabilities are the model's own JSON estimate, flagged `calibrated=False` (
|
|
41
|
+
| `llm` | any structured-output LLM via LiteLLM | OpenAI, Gemini, Anthropic, Ollama; probabilities are the model's own JSON estimate, flagged `calibrated=False` (see `?logprobs` below) |
|
|
42
|
+
| `llm:<model>?logprobs` | same, OpenAI-compatible servers exposing `logprobs` | options listed as letters, one single-token call per question; the distribution is the letters' `top_logprobs` renormalised (missing letters get 0, none present is an error); token probabilities, not calibrated (`calibrated=False`; check with `judgetap eval`); per-question calls run concurrently (max 8) and each is reported as a Call; more than 26 options is a local error raised before any request; falls back to JSON mode for good if the provider rejects logprobs |
|
|
42
43
|
|
|
43
44
|
Config: `judgetap.toml` or env vars; `sj.configure(engines=[...])` in code. Keys never logged.
|
|
44
45
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "judgetap"
|
|
3
|
-
version = "0.2.1.
|
|
3
|
+
version = "0.2.1.dev42001"
|
|
4
4
|
description = "Fast typed decisions (choice, score, yes/no) across Jev-style engines, plus a guard for coding agents. Early development."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "Apache-2.0"
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
load("jev") -> TypeSafe Jev (TYPESAFE_API_KEY)
|
|
4
4
|
load("jev:jev-1.13.0") -> Jev pinned to a model version
|
|
5
5
|
load("llm:openai/gpt-4o-mini") -> any LiteLLM model (pip install judgetap[llm])
|
|
6
|
+
load("llm:openai/m?logprobs") -> same, probabilities from token logprobs
|
|
6
7
|
"""
|
|
7
8
|
|
|
8
9
|
from __future__ import annotations
|
|
@@ -41,7 +42,10 @@ def load(spec: str | None = None) -> Engine:
|
|
|
41
42
|
raise JudgetapError("llm engine needs a model, e.g. llm:openai/gpt-4o-mini")
|
|
42
43
|
from judgetap.engines.llm import LLMEngine
|
|
43
44
|
|
|
44
|
-
|
|
45
|
+
model, _, flags = arg.partition("?")
|
|
46
|
+
if flags not in ("", "logprobs"):
|
|
47
|
+
raise JudgetapError(f"unknown llm option {flags!r}; known: logprobs")
|
|
48
|
+
return LLMEngine(model=model, logprobs=flags == "logprobs")
|
|
45
49
|
if name == "laya":
|
|
46
50
|
from judgetap.engines.laya import DEFAULT_MODEL, LayaEngine
|
|
47
51
|
|
|
@@ -6,16 +6,18 @@ the request is built with the Jev adapter's helpers. Its runtime ships in the
|
|
|
6
6
|
model repo rather than on PyPI:
|
|
7
7
|
|
|
8
8
|
python -c "from huggingface_hub import snapshot_download; \\
|
|
9
|
-
snapshot_download('SupersonicLabs/Julia-1',
|
|
10
|
-
|
|
9
|
+
import os; snapshot_download('SupersonicLabs/Julia-1', \\
|
|
10
|
+
local_dir=os.path.expanduser('~/.judgetap/models/Julia-1'))"
|
|
11
|
+
pip install -e ~/.judgetap/models/Julia-1
|
|
11
12
|
|
|
12
13
|
Spec `julia` loads the checkpoint at $JUDGETAP_JULIA_PATH (default
|
|
13
14
|
`~/.judgetap/models/Julia-1`); `julia:<path>` names it. A relative path is
|
|
14
|
-
taken from ~/.judgetap/models, never the working directory
|
|
15
|
-
|
|
16
|
-
judges them. $JUDGETAP_JULIA_DEVICE picks the device (default `cpu`).
|
|
15
|
+
taken from ~/.judgetap/models, never the working directory, and may not
|
|
16
|
+
escape it (`../x` is rejected): the guard runs inside untrusted repos, which
|
|
17
|
+
must not be able to supply the model that judges them. $JUDGETAP_JULIA_DEVICE picks the device (default `cpu`).
|
|
17
18
|
|
|
18
|
-
Loaded models are cached per process (path, device),
|
|
19
|
+
Loaded models are cached per process (path, device), at most
|
|
20
|
+
MAX_CACHED_MODELS of them (least recently used evicted), so a long-running
|
|
19
21
|
program loads each once. The guard hook is a new process per action, so an
|
|
20
22
|
in-process model still loads on every guarded action there: for the guard,
|
|
21
23
|
prefer a server engine (AgentJev, or a TypeSafe-compatible URL).
|
|
@@ -36,7 +38,8 @@ from judgetap.errors import JudgetapError
|
|
|
36
38
|
from judgetap.types import NO, YES, Question
|
|
37
39
|
|
|
38
40
|
DEFAULT_PATH = "Julia-1"
|
|
39
|
-
|
|
41
|
+
MAX_CACHED_MODELS = 2
|
|
42
|
+
_models: dict[tuple[str, str], Any] = {} # insertion order = LRU order
|
|
40
43
|
_models_lock = threading.Lock()
|
|
41
44
|
|
|
42
45
|
|
|
@@ -47,9 +50,21 @@ def _models_dir() -> Path:
|
|
|
47
50
|
|
|
48
51
|
|
|
49
52
|
def resolve_path(path: str) -> str:
|
|
50
|
-
"""Absolute paths as given; anything else under ~/.judgetap/models.
|
|
53
|
+
"""Absolute paths as given; anything else under ~/.judgetap/models.
|
|
54
|
+
|
|
55
|
+
A relative path that resolves outside the models directory (``../x``,
|
|
56
|
+
or a symlink out of it) is rejected.
|
|
57
|
+
"""
|
|
51
58
|
p = Path(path).expanduser()
|
|
52
|
-
|
|
59
|
+
if p.is_absolute():
|
|
60
|
+
return str(p)
|
|
61
|
+
base = _models_dir()
|
|
62
|
+
if not (base / p).resolve().is_relative_to(base.resolve()):
|
|
63
|
+
raise ValueError(
|
|
64
|
+
f"julia model path {path!r} escapes {base}; "
|
|
65
|
+
"use an absolute path for a model outside it"
|
|
66
|
+
)
|
|
67
|
+
return str(base / p)
|
|
53
68
|
|
|
54
69
|
|
|
55
70
|
# Julia's native limits: 2-20 options per question.
|
|
@@ -90,7 +105,9 @@ class JuliaEngine:
|
|
|
90
105
|
key = (self.path, self.device)
|
|
91
106
|
# One load per process and model, even with concurrent first calls.
|
|
92
107
|
with _models_lock:
|
|
93
|
-
if key
|
|
108
|
+
if key in _models:
|
|
109
|
+
_models[key] = _models.pop(key) # mark most recently used
|
|
110
|
+
else:
|
|
94
111
|
try:
|
|
95
112
|
from julia import load_model
|
|
96
113
|
except ImportError as err:
|
|
@@ -106,6 +123,8 @@ class JuliaEngine:
|
|
|
106
123
|
max_length=8192,
|
|
107
124
|
head_length=512,
|
|
108
125
|
)
|
|
126
|
+
while len(_models) > MAX_CACHED_MODELS:
|
|
127
|
+
del _models[next(iter(_models))]
|
|
109
128
|
self._runtime = _models[key]
|
|
110
129
|
return self._runtime
|
|
111
130
|
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Any chat model through LiteLLM.
|
|
2
|
+
|
|
3
|
+
Two modes:
|
|
4
|
+
- JSON (default): the model is asked for a JSON distribution. Those numbers
|
|
5
|
+
are its own estimate, so answers are marked `calibrated=False`.
|
|
6
|
+
- logprobs (`llm:<model>?logprobs`): each question is one call that lists the
|
|
7
|
+
options as letters and reads the letter's token probabilities from the
|
|
8
|
+
server's `top_logprobs`. These are the model's token probabilities, not a
|
|
9
|
+
validated confidence, so answers are still `calibrated=False`; use
|
|
10
|
+
`judgetap eval` to check how well they track accuracy. One call per
|
|
11
|
+
question (run concurrently, at most MAX_CONCURRENT at once), each generating
|
|
12
|
+
a single token; every call is reported so metrics count them. If the
|
|
13
|
+
provider rejects logprobs, the engine falls back to JSON mode for good.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import json
|
|
20
|
+
import math
|
|
21
|
+
import string
|
|
22
|
+
import time
|
|
23
|
+
from collections.abc import Sequence
|
|
24
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from judgetap.engine import Call, Context, RawAnswer, plain_context
|
|
28
|
+
from judgetap.errors import JudgetapError
|
|
29
|
+
from judgetap.types import Question
|
|
30
|
+
|
|
31
|
+
SYSTEM = (
|
|
32
|
+
"You answer typed questions about the given state. For each question, "
|
|
33
|
+
"return a probability for every allowed option; probabilities for one "
|
|
34
|
+
"question sum to 1. Reply with JSON only: "
|
|
35
|
+
'{"answers": {"<question id>": {"<option>": <probability>, ...}, ...}}'
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class LLMError(JudgetapError):
|
|
40
|
+
"""The model call failed or its reply could not be parsed."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _prompt(questions: Sequence[Question], context: Context) -> str:
|
|
44
|
+
state = plain_context(context)
|
|
45
|
+
state = state if isinstance(state, str) else json.dumps(state)
|
|
46
|
+
lines = [f"State:\n{state}\n", "Questions:"]
|
|
47
|
+
for i, q in enumerate(questions):
|
|
48
|
+
order = " (ordered lowest to highest)" if q.kind == "score" else ""
|
|
49
|
+
lines.append(
|
|
50
|
+
f"- q{i}: {q.text}\n options{order}: {json.dumps(list(q.options))}"
|
|
51
|
+
)
|
|
52
|
+
return "\n".join(lines)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
LETTERS = string.ascii_uppercase
|
|
56
|
+
MAX_CONCURRENT = 8 # per-question logprobs calls in flight at once
|
|
57
|
+
LOGPROBS_SYSTEM = (
|
|
58
|
+
"You answer one question about the given state by choosing one option. "
|
|
59
|
+
"Reply with the option's letter only, nothing else."
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _letter_prompt(question: Question, context: Context) -> str:
|
|
64
|
+
state = plain_context(context)
|
|
65
|
+
state = state if isinstance(state, str) else json.dumps(state)
|
|
66
|
+
order = " (ordered lowest to highest)" if question.kind == "score" else ""
|
|
67
|
+
lines = [f"State:\n{state}\n", f"Question: {question.text}", f"Options{order}:"]
|
|
68
|
+
lines += [f"{LETTERS[i]}. {option}" for i, option in enumerate(question.options)]
|
|
69
|
+
lines.append("Answer with one letter.")
|
|
70
|
+
return "\n".join(lines)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _field(obj: Any, name: str) -> Any:
|
|
74
|
+
"""Read a field from an OpenAI-shaped object or a plain dict."""
|
|
75
|
+
return obj.get(name) if isinstance(obj, dict) else getattr(obj, name, None)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _letter_distribution(
|
|
79
|
+
question: Question, response: Any, model: str
|
|
80
|
+
) -> dict[str, float]:
|
|
81
|
+
"""Option -> probability from the first token's top_logprobs, matching
|
|
82
|
+
letters case-insensitively and ignoring surrounding whitespace."""
|
|
83
|
+
try:
|
|
84
|
+
first = _field(_field(_field(response, "choices")[0], "logprobs"), "content")[0]
|
|
85
|
+
tops = _field(first, "top_logprobs") or []
|
|
86
|
+
except (TypeError, IndexError, AttributeError) as err:
|
|
87
|
+
raise LLMError(f"{model} returned no logprobs: {err!r}") from err
|
|
88
|
+
letters = {LETTERS[i]: option for i, option in enumerate(question.options)}
|
|
89
|
+
mass = dict.fromkeys(question.options, 0.0)
|
|
90
|
+
for top in tops:
|
|
91
|
+
token = str(_field(top, "token") or "").strip().upper()
|
|
92
|
+
logprob = _field(top, "logprob")
|
|
93
|
+
if token in letters and isinstance(logprob, int | float):
|
|
94
|
+
mass[letters[token]] += math.exp(logprob)
|
|
95
|
+
total = sum(mass.values())
|
|
96
|
+
if total <= 0:
|
|
97
|
+
raise LLMError(f"{model} put no probability on any option letter")
|
|
98
|
+
return {option: p / total for option, p in mass.items()}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _rejects_logprobs(litellm: Any, err: Exception) -> bool:
|
|
102
|
+
"""True when the provider refused the logprobs parameters themselves.
|
|
103
|
+
Only provider errors count: judgetap's own errors (an option-count limit,
|
|
104
|
+
an unparseable reply) never switch the engine to JSON mode."""
|
|
105
|
+
if isinstance(err, JudgetapError):
|
|
106
|
+
return False
|
|
107
|
+
kinds = tuple(
|
|
108
|
+
k
|
|
109
|
+
for k in (
|
|
110
|
+
getattr(litellm, "UnsupportedParamsError", None),
|
|
111
|
+
getattr(litellm, "BadRequestError", None),
|
|
112
|
+
)
|
|
113
|
+
if isinstance(k, type)
|
|
114
|
+
)
|
|
115
|
+
if kinds and isinstance(err, getattr(litellm, "UnsupportedParamsError", ())):
|
|
116
|
+
return True
|
|
117
|
+
return bool(kinds) and isinstance(err, kinds) and "logprob" in str(err).lower()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class LLMEngine:
|
|
121
|
+
def __init__(
|
|
122
|
+
self, model: str, *, logprobs: bool = False, **completion_kwargs: Any
|
|
123
|
+
) -> None:
|
|
124
|
+
self.name = f"llm:{model}"
|
|
125
|
+
self.model = model
|
|
126
|
+
self.logprobs = logprobs
|
|
127
|
+
self._kwargs = completion_kwargs
|
|
128
|
+
|
|
129
|
+
def _messages(self, questions: Sequence[Question], context: Context) -> list[dict]:
|
|
130
|
+
return [
|
|
131
|
+
{"role": "system", "content": SYSTEM},
|
|
132
|
+
{"role": "user", "content": _prompt(questions, context)},
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
def _parse(self, questions: Sequence[Question], response: Any) -> list[RawAnswer]:
|
|
136
|
+
try:
|
|
137
|
+
content = response.choices[0].message.content
|
|
138
|
+
answers = json.loads(content)["answers"]
|
|
139
|
+
try:
|
|
140
|
+
from litellm import completion_cost
|
|
141
|
+
|
|
142
|
+
cost = completion_cost(completion_response=response) / len(questions)
|
|
143
|
+
except Exception: # noqa: BLE001 -- LiteLLM raises assorted errors for unpriced models
|
|
144
|
+
cost = None
|
|
145
|
+
return [
|
|
146
|
+
RawAnswer(dict(answers[f"q{i}"]), cost_usd=cost, calibrated=False)
|
|
147
|
+
for i in range(len(questions))
|
|
148
|
+
]
|
|
149
|
+
except (KeyError, TypeError, ValueError, IndexError, AttributeError) as err:
|
|
150
|
+
raise LLMError(f"could not parse {self.model} reply: {err!r}") from err
|
|
151
|
+
|
|
152
|
+
def _check_options(self, questions: Sequence[Question]) -> None:
|
|
153
|
+
"""Before any provider call: a local limit, never a provider refusal."""
|
|
154
|
+
for q in questions:
|
|
155
|
+
if len(q.options) > len(LETTERS):
|
|
156
|
+
raise LLMError(
|
|
157
|
+
f"logprobs mode handles up to {len(LETTERS)} options; "
|
|
158
|
+
f"{q.text!r} has {len(q.options)}"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def _letter_call(self, question: Question, context: Context) -> dict[str, Any]:
|
|
162
|
+
return {
|
|
163
|
+
"model": self.model,
|
|
164
|
+
"messages": [
|
|
165
|
+
{"role": "system", "content": LOGPROBS_SYSTEM},
|
|
166
|
+
{"role": "user", "content": _letter_prompt(question, context)},
|
|
167
|
+
],
|
|
168
|
+
"max_tokens": 1,
|
|
169
|
+
"logprobs": True,
|
|
170
|
+
"top_logprobs": 20,
|
|
171
|
+
**self._kwargs,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
def _letter_answer(self, question: Question, response: Any) -> RawAnswer:
|
|
175
|
+
try:
|
|
176
|
+
from litellm import completion_cost
|
|
177
|
+
|
|
178
|
+
cost = completion_cost(completion_response=response)
|
|
179
|
+
except Exception: # noqa: BLE001 -- LiteLLM raises assorted errors for unpriced models
|
|
180
|
+
cost = None
|
|
181
|
+
return RawAnswer(
|
|
182
|
+
_letter_distribution(question, response, self.model),
|
|
183
|
+
cost_usd=cost,
|
|
184
|
+
calibrated=False, # token probabilities, not validated confidence
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def _with_calls(
|
|
188
|
+
self, answers: list[RawAnswer], timings: list[float]
|
|
189
|
+
) -> list[RawAnswer]:
|
|
190
|
+
"""Every answer carries the batch's calls: one per provider request."""
|
|
191
|
+
calls = tuple(Call(self.name, ms, True, 1) for ms in timings)
|
|
192
|
+
return [
|
|
193
|
+
RawAnswer(
|
|
194
|
+
a.distribution,
|
|
195
|
+
cost_usd=a.cost_usd,
|
|
196
|
+
calibrated=a.calibrated,
|
|
197
|
+
calls=calls,
|
|
198
|
+
)
|
|
199
|
+
for a in answers
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
def _one(
|
|
203
|
+
self, litellm: Any, q: Question, context: Context
|
|
204
|
+
) -> tuple[RawAnswer, float]:
|
|
205
|
+
start = time.perf_counter()
|
|
206
|
+
response = litellm.completion(**self._letter_call(q, context))
|
|
207
|
+
return self._letter_answer(q, response), (time.perf_counter() - start) * 1000
|
|
208
|
+
|
|
209
|
+
def decide(
|
|
210
|
+
self, questions: Sequence[Question], context: Context
|
|
211
|
+
) -> Sequence[RawAnswer]:
|
|
212
|
+
litellm = _import_litellm()
|
|
213
|
+
if self.logprobs:
|
|
214
|
+
self._check_options(questions)
|
|
215
|
+
try:
|
|
216
|
+
workers = min(MAX_CONCURRENT, max(1, len(questions)))
|
|
217
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
218
|
+
done = list(
|
|
219
|
+
pool.map(lambda q: self._one(litellm, q, context), questions)
|
|
220
|
+
)
|
|
221
|
+
return self._with_calls([a for a, _ in done], [ms for _, ms in done])
|
|
222
|
+
except Exception as err:
|
|
223
|
+
if not _rejects_logprobs(litellm, err):
|
|
224
|
+
raise
|
|
225
|
+
self.logprobs = False # this provider can't: JSON mode from now on
|
|
226
|
+
response = litellm.completion(
|
|
227
|
+
model=self.model,
|
|
228
|
+
messages=self._messages(questions, context),
|
|
229
|
+
response_format={"type": "json_object"},
|
|
230
|
+
**self._kwargs,
|
|
231
|
+
)
|
|
232
|
+
return self._parse(questions, response)
|
|
233
|
+
|
|
234
|
+
async def adecide(
|
|
235
|
+
self, questions: Sequence[Question], context: Context
|
|
236
|
+
) -> Sequence[RawAnswer]:
|
|
237
|
+
litellm = _import_litellm()
|
|
238
|
+
if self.logprobs:
|
|
239
|
+
self._check_options(questions)
|
|
240
|
+
gate = asyncio.Semaphore(MAX_CONCURRENT)
|
|
241
|
+
|
|
242
|
+
async def one(q: Question) -> tuple[RawAnswer, float]:
|
|
243
|
+
async with gate:
|
|
244
|
+
start = time.perf_counter()
|
|
245
|
+
response = await litellm.acompletion(
|
|
246
|
+
**self._letter_call(q, context)
|
|
247
|
+
)
|
|
248
|
+
ms = (time.perf_counter() - start) * 1000
|
|
249
|
+
return self._letter_answer(q, response), ms
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
done = await asyncio.gather(*(one(q) for q in questions))
|
|
253
|
+
return self._with_calls([a for a, _ in done], [ms for _, ms in done])
|
|
254
|
+
except Exception as err:
|
|
255
|
+
if not _rejects_logprobs(litellm, err):
|
|
256
|
+
raise
|
|
257
|
+
self.logprobs = False
|
|
258
|
+
response = await litellm.acompletion(
|
|
259
|
+
model=self.model,
|
|
260
|
+
messages=self._messages(questions, context),
|
|
261
|
+
response_format={"type": "json_object"},
|
|
262
|
+
**self._kwargs,
|
|
263
|
+
)
|
|
264
|
+
return self._parse(questions, response)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _import_litellm():
|
|
268
|
+
try:
|
|
269
|
+
import litellm
|
|
270
|
+
except ImportError as err:
|
|
271
|
+
raise LLMError(
|
|
272
|
+
"the llm engine needs LiteLLM: pip install 'judgetap[llm]'"
|
|
273
|
+
) from err
|
|
274
|
+
return litellm
|
|
@@ -200,7 +200,9 @@ def agentjev_up(
|
|
|
200
200
|
return True
|
|
201
201
|
|
|
202
202
|
|
|
203
|
-
SPEC_PATTERN = re.compile(
|
|
203
|
+
SPEC_PATTERN = re.compile(
|
|
204
|
+
r"[A-Za-z0-9:_./@+\[\]?-]+"
|
|
205
|
+
) # [ ] for IPv6 hosts, ? for llm options
|
|
204
206
|
|
|
205
207
|
|
|
206
208
|
KNOWN_ENGINES = frozenset({"jev", "llm", "laya", "julia", "agentjev", "typesafe"})
|
|
@@ -167,3 +167,33 @@ def test_model_loads_once_per_process_even_concurrently(tmp_path, monkeypatch):
|
|
|
167
167
|
t.join()
|
|
168
168
|
assert len(loads) == 1
|
|
169
169
|
assert len({id(e._runtime) for e in engines}) == 1
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@pytest.mark.parametrize("spec", ["../Julia-1", "a/../../x", "../../etc"])
|
|
173
|
+
def test_relative_paths_escaping_models_dir_are_rejected(spec, tmp_path, monkeypatch):
|
|
174
|
+
monkeypatch.setenv("JUDGETAP_HOME", str(tmp_path / "home"))
|
|
175
|
+
with pytest.raises(ValueError, match="escapes"):
|
|
176
|
+
JuliaEngine(spec)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_relative_path_inside_models_dir_with_dotdot_is_allowed(tmp_path, monkeypatch):
|
|
180
|
+
monkeypatch.setenv("JUDGETAP_HOME", str(tmp_path / "home"))
|
|
181
|
+
path = JuliaEngine("a/../Julia-1").path
|
|
182
|
+
assert path == str(tmp_path / "home" / "models" / "a" / ".." / "Julia-1")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_model_cache_keeps_two_most_recently_used(fake_julia, tmp_path, monkeypatch):
|
|
186
|
+
from judgetap.engines import julia as jmod
|
|
187
|
+
|
|
188
|
+
monkeypatch.setenv("JUDGETAP_HOME", str(tmp_path))
|
|
189
|
+
monkeypatch.setattr(jmod, "_models", {})
|
|
190
|
+
jmod.JuliaEngine("A")._get_runtime()
|
|
191
|
+
jmod.JuliaEngine("B")._get_runtime()
|
|
192
|
+
jmod.JuliaEngine("A")._get_runtime() # A is now most recent
|
|
193
|
+
jmod.JuliaEngine("C")._get_runtime() # evicts B
|
|
194
|
+
models = tmp_path / "models"
|
|
195
|
+
assert [k[0] for k in jmod._models] == [str(models / "A"), str(models / "C")]
|
|
196
|
+
assert len(fake_julia.loads) == 3
|
|
197
|
+
jmod.JuliaEngine("B")._get_runtime() # reloads B, evicts A
|
|
198
|
+
assert len(fake_julia.loads) == 4
|
|
199
|
+
assert [k[0] for k in jmod._models] == [str(models / "C"), str(models / "B")]
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import math
|
|
3
|
+
import sys
|
|
4
|
+
import types
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
import judgetap as jt
|
|
9
|
+
from judgetap.engines import load
|
|
10
|
+
from judgetap.engines.llm import LLMEngine, LLMError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def lp_response(tops):
|
|
14
|
+
"""An OpenAI-shaped chat completion whose first token has these top_logprobs."""
|
|
15
|
+
content = [
|
|
16
|
+
types.SimpleNamespace(
|
|
17
|
+
top_logprobs=[
|
|
18
|
+
types.SimpleNamespace(token=t, logprob=math.log(p)) for t, p in tops
|
|
19
|
+
]
|
|
20
|
+
)
|
|
21
|
+
]
|
|
22
|
+
choice = types.SimpleNamespace(
|
|
23
|
+
logprobs=types.SimpleNamespace(content=content),
|
|
24
|
+
message=types.SimpleNamespace(content="A"),
|
|
25
|
+
)
|
|
26
|
+
return types.SimpleNamespace(choices=[choice])
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def json_response(text):
|
|
30
|
+
msg = types.SimpleNamespace(content=text)
|
|
31
|
+
return types.SimpleNamespace(
|
|
32
|
+
choices=[types.SimpleNamespace(message=msg, logprobs=None)]
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FakeLiteLLM(types.ModuleType):
|
|
37
|
+
def __init__(self, handler):
|
|
38
|
+
super().__init__("litellm")
|
|
39
|
+
self.handler = handler
|
|
40
|
+
self.calls = []
|
|
41
|
+
|
|
42
|
+
class UnsupportedParamsError(Exception):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
self.UnsupportedParamsError = UnsupportedParamsError
|
|
46
|
+
|
|
47
|
+
def completion(self, **kw):
|
|
48
|
+
self.calls.append(kw)
|
|
49
|
+
return self.handler(self, kw)
|
|
50
|
+
|
|
51
|
+
async def acompletion(self, **kw):
|
|
52
|
+
return self.completion(**kw)
|
|
53
|
+
|
|
54
|
+
def completion_cost(self, completion_response):
|
|
55
|
+
raise ValueError("unpriced")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@pytest.fixture
|
|
59
|
+
def fake(monkeypatch):
|
|
60
|
+
def install(handler):
|
|
61
|
+
mod = FakeLiteLLM(handler)
|
|
62
|
+
monkeypatch.setitem(sys.modules, "litellm", mod)
|
|
63
|
+
return mod
|
|
64
|
+
|
|
65
|
+
return install
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_distribution_from_letter_logprobs(fake):
|
|
69
|
+
lit = fake(
|
|
70
|
+
lambda m, kw: lp_response([("A", 0.7), (" b", 0.2), ("C", 0.05), ("Z", 0.05)])
|
|
71
|
+
)
|
|
72
|
+
d = jt.choice(
|
|
73
|
+
"route?",
|
|
74
|
+
["billing", "tech", "sales"],
|
|
75
|
+
engine=LLMEngine("openai/x", logprobs=True),
|
|
76
|
+
)
|
|
77
|
+
assert (
|
|
78
|
+
d.value == "billing" and not d.calibrated
|
|
79
|
+
) # token probabilities, not calibrated
|
|
80
|
+
assert d.distribution["billing"] == pytest.approx(0.7 / 0.95)
|
|
81
|
+
assert d.distribution["tech"] == pytest.approx(0.2 / 0.95)
|
|
82
|
+
call = lit.calls[0]
|
|
83
|
+
assert (
|
|
84
|
+
call["max_tokens"] == 1
|
|
85
|
+
and call["logprobs"] is True
|
|
86
|
+
and call["top_logprobs"] == 20
|
|
87
|
+
)
|
|
88
|
+
assert "A. billing" in call["messages"][1]["content"]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_missing_letters_get_zero(fake):
|
|
92
|
+
fake(lambda m, kw: lp_response([("B", 0.9), ("x", 0.1)]))
|
|
93
|
+
d = jt.yesno("q", engine=LLMEngine("openai/x", logprobs=True))
|
|
94
|
+
assert d.distribution == {"yes": 0.0, "no": 1.0}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_no_option_letter_is_an_error_so_a_cascade_falls_through(fake):
|
|
98
|
+
fake(lambda m, kw: lp_response([("x", 0.9), ("y", 0.1)]))
|
|
99
|
+
with pytest.raises(LLMError, match="no probability"):
|
|
100
|
+
jt.yesno("q", engine=LLMEngine("openai/x", logprobs=True))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_one_call_per_question(fake):
|
|
104
|
+
lit = fake(lambda m, kw: lp_response([("A", 1.0)]))
|
|
105
|
+
jt.batch(
|
|
106
|
+
[jt.Question.yesno("a"), jt.Question.yesno("b")],
|
|
107
|
+
engine=LLMEngine("openai/x", logprobs=True),
|
|
108
|
+
)
|
|
109
|
+
assert len(lit.calls) == 2
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_provider_rejecting_logprobs_falls_back_to_json_for_good(fake):
|
|
113
|
+
def handler(m, kw):
|
|
114
|
+
if kw.get("logprobs"):
|
|
115
|
+
raise m.UnsupportedParamsError("logprobs is not supported")
|
|
116
|
+
return json_response('{"answers": {"q0": {"yes": 0.8, "no": 0.2}}}')
|
|
117
|
+
|
|
118
|
+
lit = fake(handler)
|
|
119
|
+
engine = LLMEngine("openai/x", logprobs=True)
|
|
120
|
+
d = jt.yesno("q", engine=engine)
|
|
121
|
+
assert d.value == "yes" and not d.calibrated and engine.logprobs is False
|
|
122
|
+
jt.yesno("q", engine=engine)
|
|
123
|
+
assert sum(1 for c in lit.calls if c.get("logprobs")) == 1 # not retried
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_other_errors_are_not_swallowed(fake):
|
|
127
|
+
def handler(m, kw):
|
|
128
|
+
raise RuntimeError("rate limited")
|
|
129
|
+
|
|
130
|
+
fake(handler)
|
|
131
|
+
with pytest.raises(RuntimeError):
|
|
132
|
+
jt.yesno("q", engine=LLMEngine("openai/x", logprobs=True))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_async_logprobs(fake):
|
|
136
|
+
fake(lambda m, kw: lp_response([("A", 0.6), ("B", 0.4)]))
|
|
137
|
+
d = asyncio.run(jt.ayesno("q", engine=LLMEngine("openai/x", logprobs=True)))
|
|
138
|
+
assert d.value == "yes" and d.p == pytest.approx(0.6)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_dict_shaped_logprobs(fake):
|
|
142
|
+
choice = types.SimpleNamespace(
|
|
143
|
+
logprobs={"content": [{"top_logprobs": [{"token": "b", "logprob": 0.0}]}]}
|
|
144
|
+
)
|
|
145
|
+
resp = types.SimpleNamespace(choices=[choice])
|
|
146
|
+
fake(lambda m, kw: resp)
|
|
147
|
+
assert jt.yesno("q", engine=LLMEngine("openai/x", logprobs=True)).value == "no"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_too_many_options_for_letters():
|
|
151
|
+
q = jt.Question.choice("q", [str(i) for i in range(27)])
|
|
152
|
+
with pytest.raises(LLMError, match="up to 26"):
|
|
153
|
+
LLMEngine("m", logprobs=True)._check_options([q])
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_spec_parsing():
|
|
157
|
+
assert load("llm:openai/qwen?logprobs").logprobs is True
|
|
158
|
+
e = load("llm:openai/qwen")
|
|
159
|
+
assert e.logprobs is False and e.model == "openai/qwen"
|
|
160
|
+
with pytest.raises(jt.JudgetapError):
|
|
161
|
+
load("llm:openai/qwen?bogus")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_spec_can_be_saved_to_guard_toml(tmp_path):
|
|
165
|
+
from judgetap.guard.install import write_engine
|
|
166
|
+
|
|
167
|
+
write_engine(tmp_path, "llm:openai/qwen?logprobs")
|
|
168
|
+
assert (
|
|
169
|
+
'engine = "llm:openai/qwen?logprobs"' in (tmp_path / "guard.toml").read_text()
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_option_limit_is_local_and_keeps_logprobs_enabled(fake):
|
|
174
|
+
lit = fake(lambda m, kw: lp_response([("A", 0.9), ("B", 0.1)]))
|
|
175
|
+
engine = LLMEngine("openai/x", logprobs=True)
|
|
176
|
+
too_many = [str(i) for i in range(27)]
|
|
177
|
+
with pytest.raises(LLMError, match="up to 26 options"):
|
|
178
|
+
jt.choice("q", too_many, engine=engine)
|
|
179
|
+
assert engine.logprobs is True and lit.calls == [] # no request, no fallback
|
|
180
|
+
d = jt.choice("q", ["a", "b"], engine=engine)
|
|
181
|
+
assert d.value == "a" and lit.calls[-1].get("logprobs")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_bad_request_mentioning_logprobs_falls_back(fake):
|
|
185
|
+
def handler(m, kw):
|
|
186
|
+
if kw.get("logprobs"):
|
|
187
|
+
raise m.BadRequestError("this model does not support logprobs")
|
|
188
|
+
return json_response('{"answers": {"q0": {"yes": 0.8, "no": 0.2}}}')
|
|
189
|
+
|
|
190
|
+
lit = fake(handler)
|
|
191
|
+
|
|
192
|
+
class BadRequestError(Exception):
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
lit.BadRequestError = BadRequestError
|
|
196
|
+
engine = LLMEngine("openai/x", logprobs=True)
|
|
197
|
+
assert jt.yesno("q", engine=engine).value == "yes" and engine.logprobs is False
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_batch_questions_run_concurrently_in_order(fake):
|
|
201
|
+
import threading
|
|
202
|
+
|
|
203
|
+
lock, state = threading.Lock(), {"now": 0, "peak": 0}
|
|
204
|
+
gate = threading.Barrier(3, timeout=2)
|
|
205
|
+
|
|
206
|
+
def handler(m, kw):
|
|
207
|
+
with lock:
|
|
208
|
+
state["now"] += 1
|
|
209
|
+
state["peak"] = max(state["peak"], state["now"])
|
|
210
|
+
gate.wait() # only passes if all three requests are in flight together
|
|
211
|
+
with lock:
|
|
212
|
+
state["now"] -= 1
|
|
213
|
+
letter = "A" if "first" in kw["messages"][1]["content"] else "B"
|
|
214
|
+
return lp_response([(letter, 1.0)])
|
|
215
|
+
|
|
216
|
+
fake(handler)
|
|
217
|
+
qs = [
|
|
218
|
+
jt.Question.yesno("first"),
|
|
219
|
+
jt.Question.yesno("second"),
|
|
220
|
+
jt.Question.yesno("third"),
|
|
221
|
+
]
|
|
222
|
+
out = jt.batch(qs, engine=LLMEngine("openai/x", logprobs=True))
|
|
223
|
+
assert state["peak"] == 3
|
|
224
|
+
assert [d.value for d in out] == ["yes", "no", "no"]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def test_async_batch_runs_concurrently(fake):
|
|
228
|
+
lit = fake(lambda m, kw: lp_response([("A", 1.0)]))
|
|
229
|
+
active = {"now": 0, "peak": 0}
|
|
230
|
+
|
|
231
|
+
async def acompletion(**kw):
|
|
232
|
+
active["now"] += 1
|
|
233
|
+
active["peak"] = max(active["peak"], active["now"])
|
|
234
|
+
await asyncio.sleep(0.01)
|
|
235
|
+
active["now"] -= 1
|
|
236
|
+
return lp_response([("A", 1.0)])
|
|
237
|
+
|
|
238
|
+
lit.acompletion = acompletion
|
|
239
|
+
qs = [jt.Question.yesno(str(i)) for i in range(5)]
|
|
240
|
+
asyncio.run(jt.abatch(qs, engine=LLMEngine("openai/x", logprobs=True)))
|
|
241
|
+
assert active["peak"] == 5
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_plain_dict_response_is_read(fake):
|
|
245
|
+
def handler(m, kw):
|
|
246
|
+
return {
|
|
247
|
+
"choices": [
|
|
248
|
+
{
|
|
249
|
+
"logprobs": {
|
|
250
|
+
"content": [{"top_logprobs": [{"token": "B", "logprob": 0.0}]}]
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
]
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
fake(handler)
|
|
257
|
+
d = jt.choice("q", ["a", "b"], engine=LLMEngine("openai/x", logprobs=True))
|
|
258
|
+
assert d.value == "b"
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def test_each_request_is_reported_as_a_call(fake):
|
|
262
|
+
fake(lambda m, kw: lp_response([("A", 1.0)]))
|
|
263
|
+
qs = [jt.Question.yesno(str(i)) for i in range(3)]
|
|
264
|
+
out = jt.batch(qs, engine=LLMEngine("openai/x", logprobs=True))
|
|
265
|
+
assert len(out[0].calls) == 3
|
|
266
|
+
assert all(c.engine == "llm:openai/x" and c.questions == 1 for c in out[0].calls)
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
"""Any chat model through LiteLLM, asked for a JSON distribution.
|
|
2
|
-
|
|
3
|
-
LLMs rarely expose calibrated probabilities for a label set, so answers
|
|
4
|
-
from this engine are marked `calibrated=False`: the numbers are the model's
|
|
5
|
-
own estimate, useful for ranking and escalation, not as true frequencies.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
import json
|
|
11
|
-
from collections.abc import Sequence
|
|
12
|
-
from typing import Any
|
|
13
|
-
|
|
14
|
-
from judgetap.engine import Context, RawAnswer, plain_context
|
|
15
|
-
from judgetap.errors import JudgetapError
|
|
16
|
-
from judgetap.types import Question
|
|
17
|
-
|
|
18
|
-
SYSTEM = (
|
|
19
|
-
"You answer typed questions about the given state. For each question, "
|
|
20
|
-
"return a probability for every allowed option; probabilities for one "
|
|
21
|
-
"question sum to 1. Reply with JSON only: "
|
|
22
|
-
'{"answers": {"<question id>": {"<option>": <probability>, ...}, ...}}'
|
|
23
|
-
)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
class LLMError(JudgetapError):
|
|
27
|
-
"""The model call failed or its reply could not be parsed."""
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def _prompt(questions: Sequence[Question], context: Context) -> str:
|
|
31
|
-
state = plain_context(context)
|
|
32
|
-
state = state if isinstance(state, str) else json.dumps(state)
|
|
33
|
-
lines = [f"State:\n{state}\n", "Questions:"]
|
|
34
|
-
for i, q in enumerate(questions):
|
|
35
|
-
order = " (ordered lowest to highest)" if q.kind == "score" else ""
|
|
36
|
-
lines.append(
|
|
37
|
-
f"- q{i}: {q.text}\n options{order}: {json.dumps(list(q.options))}"
|
|
38
|
-
)
|
|
39
|
-
return "\n".join(lines)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
class LLMEngine:
|
|
43
|
-
def __init__(self, model: str, **completion_kwargs: Any) -> None:
|
|
44
|
-
self.name = f"llm:{model}"
|
|
45
|
-
self.model = model
|
|
46
|
-
self._kwargs = completion_kwargs
|
|
47
|
-
|
|
48
|
-
def _messages(self, questions: Sequence[Question], context: Context) -> list[dict]:
|
|
49
|
-
return [
|
|
50
|
-
{"role": "system", "content": SYSTEM},
|
|
51
|
-
{"role": "user", "content": _prompt(questions, context)},
|
|
52
|
-
]
|
|
53
|
-
|
|
54
|
-
def _parse(self, questions: Sequence[Question], response: Any) -> list[RawAnswer]:
|
|
55
|
-
try:
|
|
56
|
-
content = response.choices[0].message.content
|
|
57
|
-
answers = json.loads(content)["answers"]
|
|
58
|
-
try:
|
|
59
|
-
from litellm import completion_cost
|
|
60
|
-
|
|
61
|
-
cost = completion_cost(completion_response=response) / len(questions)
|
|
62
|
-
except Exception: # noqa: BLE001 -- LiteLLM raises assorted errors for unpriced models
|
|
63
|
-
cost = None
|
|
64
|
-
return [
|
|
65
|
-
RawAnswer(dict(answers[f"q{i}"]), cost_usd=cost, calibrated=False)
|
|
66
|
-
for i in range(len(questions))
|
|
67
|
-
]
|
|
68
|
-
except (KeyError, TypeError, ValueError, IndexError, AttributeError) as err:
|
|
69
|
-
raise LLMError(f"could not parse {self.model} reply: {err!r}") from err
|
|
70
|
-
|
|
71
|
-
def decide(
|
|
72
|
-
self, questions: Sequence[Question], context: Context
|
|
73
|
-
) -> Sequence[RawAnswer]:
|
|
74
|
-
litellm = _import_litellm()
|
|
75
|
-
response = litellm.completion(
|
|
76
|
-
model=self.model,
|
|
77
|
-
messages=self._messages(questions, context),
|
|
78
|
-
response_format={"type": "json_object"},
|
|
79
|
-
**self._kwargs,
|
|
80
|
-
)
|
|
81
|
-
return self._parse(questions, response)
|
|
82
|
-
|
|
83
|
-
async def adecide(
|
|
84
|
-
self, questions: Sequence[Question], context: Context
|
|
85
|
-
) -> Sequence[RawAnswer]:
|
|
86
|
-
litellm = _import_litellm()
|
|
87
|
-
response = await litellm.acompletion(
|
|
88
|
-
model=self.model,
|
|
89
|
-
messages=self._messages(questions, context),
|
|
90
|
-
response_format={"type": "json_object"},
|
|
91
|
-
**self._kwargs,
|
|
92
|
-
)
|
|
93
|
-
return self._parse(questions, response)
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def _import_litellm():
|
|
97
|
-
try:
|
|
98
|
-
import litellm
|
|
99
|
-
except ImportError as err:
|
|
100
|
-
raise LLMError(
|
|
101
|
-
"the llm engine needs LiteLLM: pip install 'judgetap[llm]'"
|
|
102
|
-
) from err
|
|
103
|
-
return litellm
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|