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.
Files changed (70) hide show
  1. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/PKG-INFO +26 -1
  2. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/README.md +25 -0
  3. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/docs/SPEC.md +2 -1
  4. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/pyproject.toml +1 -1
  5. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/__init__.py +1 -1
  6. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/engines/__init__.py +5 -1
  7. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/engines/julia.py +29 -10
  8. judgetap-0.2.1.dev42001/src/judgetap/engines/llm.py +274 -0
  9. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/guard/install.py +3 -1
  10. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_engine_julia.py +30 -0
  11. judgetap-0.2.1.dev42001/tests/test_llm_logprobs.py +266 -0
  12. judgetap-0.2.1.dev40001/src/judgetap/engines/llm.py +0 -103
  13. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/.github/workflows/ci.yml +0 -0
  14. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/.github/workflows/demo.yml +0 -0
  15. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/.github/workflows/release.yml +0 -0
  16. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/.gitignore +0 -0
  17. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/.python-version +0 -0
  18. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/.release-please-manifest.json +0 -0
  19. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/CHANGELOG.md +0 -0
  20. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/CONTRIBUTING.md +0 -0
  21. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/LICENSE +0 -0
  22. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/docs/demo.tape +0 -0
  23. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/release-please-config.json +0 -0
  24. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/_compat.py +0 -0
  25. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/api.py +0 -0
  26. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/cascade.py +0 -0
  27. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/cli.py +0 -0
  28. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/dashboard/__init__.py +0 -0
  29. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/dashboard/data.py +0 -0
  30. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/dashboard/page.html +0 -0
  31. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/dashboard/server.py +0 -0
  32. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/decision_log.py +0 -0
  33. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/engine.py +0 -0
  34. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/engines/agentjev.py +0 -0
  35. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/engines/jev.py +0 -0
  36. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/engines/laya.py +0 -0
  37. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/errors.py +0 -0
  38. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/evaluate.py +0 -0
  39. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/guard/__init__.py +0 -0
  40. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/guard/core.py +0 -0
  41. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/guard/hook.py +0 -0
  42. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/guard/loop.py +0 -0
  43. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/guard/rules.py +0 -0
  44. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/guard/stop.py +0 -0
  45. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/py.typed +0 -0
  46. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/secrets.py +0 -0
  47. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/testing.py +0 -0
  48. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/src/judgetap/types.py +0 -0
  49. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_api.py +0 -0
  50. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_call_accounting.py +0 -0
  51. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_calls.py +0 -0
  52. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_cascade.py +0 -0
  53. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_dashboard.py +0 -0
  54. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_decision_log.py +0 -0
  55. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_engine_jev.py +0 -0
  56. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_engine_llm.py +0 -0
  57. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_engine_local.py +0 -0
  58. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_evaluate.py +0 -0
  59. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_guard_agents.py +0 -0
  60. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_guard_core.py +0 -0
  61. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_guard_hook.py +0 -0
  62. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_guard_loop.py +0 -0
  63. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_guard_polish_76.py +0 -0
  64. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_guard_rules.py +0 -0
  65. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_guard_stop.py +0 -0
  66. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_guard_trust.py +0 -0
  67. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_questions.py +0 -0
  68. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_robustness_68.py +0 -0
  69. {judgetap-0.2.1.dev40001 → judgetap-0.2.1.dev42001}/tests/test_secrets.py +0 -0
  70. {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.dev40001
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` (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.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"
@@ -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.dev42001" # 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
 
@@ -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', local_dir='Julia-1')"
10
- pip install -e ./Julia-1
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: the guard runs
15
- inside untrusted repos, which must not be able to supply the model that
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), so a long-running
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
- _models: dict[tuple[str, str], Any] = {}
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
- return str(p if p.is_absolute() else _models_dir() / p)
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 not in _models:
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(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"})
@@ -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