judgetap 0.2.1.dev40001__tar.gz → 0.2.1.dev41001__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.
Files changed (70) hide show
  1. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/PKG-INFO +26 -1
  2. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/README.md +25 -0
  3. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/docs/SPEC.md +2 -1
  4. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/pyproject.toml +1 -1
  5. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/src/judgetap/__init__.py +1 -1
  6. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/src/judgetap/engines/__init__.py +5 -1
  7. judgetap-0.2.1.dev41001/src/judgetap/engines/llm.py +274 -0
  8. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/src/judgetap/guard/install.py +3 -1
  9. judgetap-0.2.1.dev41001/tests/test_llm_logprobs.py +266 -0
  10. judgetap-0.2.1.dev40001/src/judgetap/engines/llm.py +0 -103
  11. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/.github/workflows/ci.yml +0 -0
  12. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/.github/workflows/demo.yml +0 -0
  13. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/.github/workflows/release.yml +0 -0
  14. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/.gitignore +0 -0
  15. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/.python-version +0 -0
  16. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/.release-please-manifest.json +0 -0
  17. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/CHANGELOG.md +0 -0
  18. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/CONTRIBUTING.md +0 -0
  19. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/LICENSE +0 -0
  20. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/docs/demo.tape +0 -0
  21. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/release-please-config.json +0 -0
  22. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/src/judgetap/_compat.py +0 -0
  23. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/src/judgetap/api.py +0 -0
  24. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/src/judgetap/cascade.py +0 -0
  25. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/src/judgetap/cli.py +0 -0
  26. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/src/judgetap/dashboard/__init__.py +0 -0
  27. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/src/judgetap/dashboard/data.py +0 -0
  28. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/src/judgetap/dashboard/page.html +0 -0
  29. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/src/judgetap/dashboard/server.py +0 -0
  30. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/src/judgetap/decision_log.py +0 -0
  31. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/src/judgetap/engine.py +0 -0
  32. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/src/judgetap/engines/agentjev.py +0 -0
  33. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/src/judgetap/engines/jev.py +0 -0
  34. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/src/judgetap/engines/julia.py +0 -0
  35. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/src/judgetap/engines/laya.py +0 -0
  36. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/src/judgetap/errors.py +0 -0
  37. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/src/judgetap/evaluate.py +0 -0
  38. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/src/judgetap/guard/__init__.py +0 -0
  39. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/src/judgetap/guard/core.py +0 -0
  40. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/src/judgetap/guard/hook.py +0 -0
  41. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/src/judgetap/guard/loop.py +0 -0
  42. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/src/judgetap/guard/rules.py +0 -0
  43. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/src/judgetap/guard/stop.py +0 -0
  44. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/src/judgetap/py.typed +0 -0
  45. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/src/judgetap/secrets.py +0 -0
  46. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/src/judgetap/testing.py +0 -0
  47. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/src/judgetap/types.py +0 -0
  48. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/tests/test_api.py +0 -0
  49. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/tests/test_call_accounting.py +0 -0
  50. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/tests/test_calls.py +0 -0
  51. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/tests/test_cascade.py +0 -0
  52. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/tests/test_dashboard.py +0 -0
  53. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/tests/test_decision_log.py +0 -0
  54. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/tests/test_engine_jev.py +0 -0
  55. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/tests/test_engine_julia.py +0 -0
  56. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/tests/test_engine_llm.py +0 -0
  57. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/tests/test_engine_local.py +0 -0
  58. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/tests/test_evaluate.py +0 -0
  59. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/tests/test_guard_agents.py +0 -0
  60. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/tests/test_guard_core.py +0 -0
  61. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/tests/test_guard_hook.py +0 -0
  62. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/tests/test_guard_loop.py +0 -0
  63. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/tests/test_guard_polish_76.py +0 -0
  64. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/tests/test_guard_rules.py +0 -0
  65. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/tests/test_guard_stop.py +0 -0
  66. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/tests/test_guard_trust.py +0 -0
  67. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/tests/test_questions.py +0 -0
  68. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/tests/test_robustness_68.py +0 -0
  69. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/tests/test_secrets.py +0 -0
  70. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev41001}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: judgetap
3
- Version: 0.2.1.dev40001
3
+ Version: 0.2.1.dev41001
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` (reading logprobs is a later enhancement) |
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.dev40001"
3
+ version = "0.2.1.dev41001"
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"
@@ -23,7 +23,7 @@ from judgetap.errors import (
23
23
  )
24
24
  from judgetap.types import Decision, Question
25
25
 
26
- __version__ = "0.2.1.dev40001" # x-release-please-version
26
+ __version__ = "0.2.1.dev41001" # x-release-please-version
27
27
 
28
28
  __all__ = [
29
29
  "Cascade",
@@ -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
- return LLMEngine(model=arg)
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
 
@@ -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(r"[A-Za-z0-9:_./@+\[\]-]+") # [ ] for IPv6 hosts
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"})
@@ -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