evalkit-harness 0.1.0__py3-none-any.whl
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.
- evalkit/__init__.py +5 -0
- evalkit/adapters/__init__.py +25 -0
- evalkit/adapters/anthropic.py +179 -0
- evalkit/adapters/base.py +176 -0
- evalkit/adapters/openai.py +255 -0
- evalkit/agents/__init__.py +0 -0
- evalkit/agents/aggregate.py +414 -0
- evalkit/agents/db.py +475 -0
- evalkit/agents/loop.py +473 -0
- evalkit/agents/runner.py +352 -0
- evalkit/agents/sandbox.py +418 -0
- evalkit/agents/score.py +94 -0
- evalkit/agents/simuser.py +470 -0
- evalkit/agents/tasks/an-01.yaml +22 -0
- evalkit/agents/tasks/an-02.yaml +29 -0
- evalkit/agents/tasks/an-03.yaml +26 -0
- evalkit/agents/tasks/an-04.yaml +27 -0
- evalkit/agents/tasks/an-05.yaml +41 -0
- evalkit/agents/tasks/an-06.yaml +45 -0
- evalkit/agents/tasks/an-07.yaml +30 -0
- evalkit/agents/tasks/an-08.yaml +23 -0
- evalkit/agents/tasks/an-09.yaml +42 -0
- evalkit/agents/tasks/an-10.yaml +23 -0
- evalkit/agents/tasks/an-11.yaml +31 -0
- evalkit/agents/tasks/an-12.yaml +32 -0
- evalkit/agents/tasks/an-13.yaml +32 -0
- evalkit/agents/tasks/an-14.yaml +27 -0
- evalkit/agents/tasks/an-15.yaml +30 -0
- evalkit/agents/tasks/an-16.yaml +22 -0
- evalkit/agents/tasks/at-01.yaml +318 -0
- evalkit/agents/tasks/at-02.yaml +19 -0
- evalkit/agents/tasks/at-03.yaml +17 -0
- evalkit/agents/tasks/at-04.yaml +318 -0
- evalkit/agents/tasks/at-05.yaml +19 -0
- evalkit/agents/tasks/at-06.yaml +17 -0
- evalkit/agents/tasks/at-07.yaml +318 -0
- evalkit/agents/tasks/at-08.yaml +19 -0
- evalkit/agents/tasks/at-09.yaml +16 -0
- evalkit/agents/tasks/at-10.yaml +317 -0
- evalkit/agents/tasks/at-11.yaml +18 -0
- evalkit/agents/tasks/at-12.yaml +16 -0
- evalkit/agents/tasks/at-13.yaml +317 -0
- evalkit/agents/tasks/at-14.yaml +18 -0
- evalkit/agents/tasks/at-15.yaml +16 -0
- evalkit/agents/tasks/at-16.yaml +317 -0
- evalkit/agents/tasks.py +184 -0
- evalkit/cli.py +739 -0
- evalkit/config.py +75 -0
- evalkit/cost.py +239 -0
- evalkit/datasets/__init__.py +15 -0
- evalkit/datasets/local.py +146 -0
- evalkit/datasets/pubmedqa.py +130 -0
- evalkit/datasets/scifact.py +159 -0
- evalkit/demo/__init__.py +15 -0
- evalkit/demo/engine.py +486 -0
- evalkit/demo/guard.py +282 -0
- evalkit/demo/page.py +377 -0
- evalkit/demo/pubmed.py +66 -0
- evalkit/env.py +42 -0
- evalkit/examples/__init__.py +0 -0
- evalkit/examples/example_run/cases.jsonl +12 -0
- evalkit/examples/example_run/records.jsonl +36 -0
- evalkit/examples/example_run/run.json +35 -0
- evalkit/examples/example_run/summary.json +325 -0
- evalkit/onboarding.py +280 -0
- evalkit/pipeline/__init__.py +1 -0
- evalkit/pipeline/aggregate.py +382 -0
- evalkit/pipeline/answer.py +68 -0
- evalkit/pipeline/calibrate.py +560 -0
- evalkit/pipeline/extract.py +398 -0
- evalkit/pipeline/generate.py +233 -0
- evalkit/pipeline/judge.py +832 -0
- evalkit/pipeline/labeller.html +53 -0
- evalkit/pipeline/labeller.py +201 -0
- evalkit/prices.yaml +24 -0
- evalkit/py.typed +0 -0
- evalkit/ratelimit.py +46 -0
- evalkit/report/__init__.py +5 -0
- evalkit/report/agents.py +242 -0
- evalkit/report/html.py +502 -0
- evalkit/report/readme.py +205 -0
- evalkit/report/svg.py +254 -0
- evalkit/retry.py +62 -0
- evalkit/run.py +655 -0
- evalkit/runtime/__init__.py +20 -0
- evalkit/runtime/canonical.py +42 -0
- evalkit/runtime/code_versions.json +26 -0
- evalkit/runtime/executor.py +332 -0
- evalkit/runtime/node.py +70 -0
- evalkit/runtime/store.py +152 -0
- evalkit/runtime/versions.py +164 -0
- evalkit/runtime/writer.py +63 -0
- evalkit/sampling.py +52 -0
- evalkit/schemas/__init__.py +58 -0
- evalkit/schemas/_base.py +101 -0
- evalkit/schemas/agent.py +127 -0
- evalkit/schemas/benchmark.py +128 -0
- evalkit/schemas/calibration.py +51 -0
- evalkit/schemas/config.py +86 -0
- evalkit/schemas/summary.py +173 -0
- evalkit/scorers/__init__.py +30 -0
- evalkit/scorers/attack.py +81 -0
- evalkit/scorers/base.py +164 -0
- evalkit/scorers/deterministic.py +52 -0
- evalkit/scorers/faithfulness.py +180 -0
- evalkit/scorers/retrieval.py +83 -0
- evalkit/scorers/sql.py +306 -0
- evalkit/smoke.py +137 -0
- evalkit/stats.py +117 -0
- evalkit/tasks/__init__.py +32 -0
- evalkit/tasks/pubmedqa.yaml +25 -0
- evalkit/tasks/scifact.yaml +27 -0
- evalkit/text/__init__.py +5 -0
- evalkit/text/sentences.py +138 -0
- evalkit_harness-0.1.0.dist-info/METADATA +70 -0
- evalkit_harness-0.1.0.dist-info/RECORD +119 -0
- evalkit_harness-0.1.0.dist-info/WHEEL +4 -0
- evalkit_harness-0.1.0.dist-info/entry_points.txt +2 -0
- evalkit_harness-0.1.0.dist-info/licenses/LICENSE +21 -0
evalkit/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Provider adapters. Import providers lazily: the SDKs are optional extras."""
|
|
2
|
+
|
|
3
|
+
from evalkit.adapters.base import (
|
|
4
|
+
ALLOWED_PARAMS,
|
|
5
|
+
BatchRequest,
|
|
6
|
+
BatchResult,
|
|
7
|
+
CompletionRequest,
|
|
8
|
+
ModelAdapter,
|
|
9
|
+
RawCompletion,
|
|
10
|
+
discover,
|
|
11
|
+
get_adapter,
|
|
12
|
+
register,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"ALLOWED_PARAMS",
|
|
17
|
+
"BatchRequest",
|
|
18
|
+
"BatchResult",
|
|
19
|
+
"CompletionRequest",
|
|
20
|
+
"ModelAdapter",
|
|
21
|
+
"RawCompletion",
|
|
22
|
+
"discover",
|
|
23
|
+
"get_adapter",
|
|
24
|
+
"register",
|
|
25
|
+
]
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Anthropic Messages and Message Batches adapter (ADP-01, ADP-03).
|
|
2
|
+
|
|
3
|
+
- No sampling parameters (D-22), no refusal fallbacks (D-23), effort set explicitly from
|
|
4
|
+
request params (D-24), JSON via `output_config.format` (D-24).
|
|
5
|
+
- `output_tokens` includes thinking; thinking is reported separately as tokens_reasoning.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import inspect
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from evalkit.adapters.base import (
|
|
16
|
+
BatchRequest,
|
|
17
|
+
BatchResult,
|
|
18
|
+
CompletionRequest,
|
|
19
|
+
ModelAdapter,
|
|
20
|
+
RawCompletion,
|
|
21
|
+
check_params,
|
|
22
|
+
check_unique_ids,
|
|
23
|
+
register,
|
|
24
|
+
)
|
|
25
|
+
from evalkit.cost import Price, call_cost, load_prices, price_for
|
|
26
|
+
from evalkit.ratelimit import TokenBucket
|
|
27
|
+
from evalkit.retry import with_retries
|
|
28
|
+
from evalkit.runtime.canonical import to_jsonable
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@register("anthropic")
|
|
32
|
+
class AnthropicAdapter(ModelAdapter):
|
|
33
|
+
provider = "anthropic"
|
|
34
|
+
|
|
35
|
+
require_effort = True
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
client: Any = None,
|
|
40
|
+
prices: dict[str, Price] | None = None,
|
|
41
|
+
bucket: TokenBucket | None = None,
|
|
42
|
+
**retry: Any,
|
|
43
|
+
):
|
|
44
|
+
self._client = client
|
|
45
|
+
self._prices = prices if prices is not None else load_prices()
|
|
46
|
+
self._bucket = bucket # optional requests-per-second pacing (ADP-07)
|
|
47
|
+
self._retry = retry
|
|
48
|
+
|
|
49
|
+
async def _paced(self, call: Any) -> Any:
|
|
50
|
+
if self._bucket is not None:
|
|
51
|
+
await self._bucket.acquire()
|
|
52
|
+
return await with_retries(call, **self._retry)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def client(self) -> Any:
|
|
56
|
+
if self._client is None:
|
|
57
|
+
import anthropic # optional dependency, imported only when used
|
|
58
|
+
|
|
59
|
+
self._client = anthropic.AsyncAnthropic(max_retries=0)
|
|
60
|
+
return self._client
|
|
61
|
+
|
|
62
|
+
def build_params(self, req: CompletionRequest) -> dict[str, Any]:
|
|
63
|
+
check_params(req, require_effort=self.require_effort)
|
|
64
|
+
params: dict[str, Any] = {
|
|
65
|
+
"model": req.model_id,
|
|
66
|
+
"max_tokens": req.max_output_tokens,
|
|
67
|
+
"messages": [{"role": "user", "content": req.prompt}],
|
|
68
|
+
}
|
|
69
|
+
if req.turns:
|
|
70
|
+
params["messages"] = [{"role": r, "content": t} for r, t in req.turns]
|
|
71
|
+
if req.cache:
|
|
72
|
+
# automatic caching: the breakpoint follows the last block as the history grows
|
|
73
|
+
params["cache_control"] = {"type": "ephemeral"}
|
|
74
|
+
if req.system:
|
|
75
|
+
params["system"] = req.system
|
|
76
|
+
output_config: dict[str, Any] = {}
|
|
77
|
+
if "effort" in req.params:
|
|
78
|
+
output_config["effort"] = req.params["effort"]
|
|
79
|
+
if req.json_schema is not None:
|
|
80
|
+
output_config["format"] = {
|
|
81
|
+
"type": "json_schema",
|
|
82
|
+
"schema": to_jsonable(req.json_schema),
|
|
83
|
+
}
|
|
84
|
+
if output_config:
|
|
85
|
+
params["output_config"] = output_config
|
|
86
|
+
return params
|
|
87
|
+
|
|
88
|
+
def parse(
|
|
89
|
+
self, message: Any, req: CompletionRequest, latency_ms: int, *, batch: bool = False
|
|
90
|
+
) -> RawCompletion:
|
|
91
|
+
text = "".join(b.text for b in message.content if getattr(b, "type", "") == "text")
|
|
92
|
+
u = message.usage
|
|
93
|
+
cache_read = getattr(u, "cache_read_input_tokens", 0) or 0
|
|
94
|
+
cache_write = getattr(u, "cache_creation_input_tokens", 0) or 0
|
|
95
|
+
details = getattr(u, "output_tokens_details", None)
|
|
96
|
+
thinking = (getattr(details, "thinking_tokens", 0) or 0) if details else 0
|
|
97
|
+
thinking = min(thinking, u.output_tokens) # never bill more reasoning than output
|
|
98
|
+
tokens_in = u.input_tokens + cache_read + cache_write
|
|
99
|
+
tokens_out = max(u.output_tokens - thinking, 0)
|
|
100
|
+
cost = call_cost(
|
|
101
|
+
price_for(self._prices, req.model_id),
|
|
102
|
+
tokens_in=tokens_in,
|
|
103
|
+
tokens_out=tokens_out,
|
|
104
|
+
tokens_reasoning=thinking,
|
|
105
|
+
tokens_cached=cache_read,
|
|
106
|
+
tokens_cache_write=cache_write,
|
|
107
|
+
batch=batch,
|
|
108
|
+
)
|
|
109
|
+
stop = getattr(message, "stop_reason", "") or ""
|
|
110
|
+
return RawCompletion(
|
|
111
|
+
model=req.model,
|
|
112
|
+
model_snapshot=getattr(message, "model", "") or "",
|
|
113
|
+
params=dict(req.params),
|
|
114
|
+
raw_text=text,
|
|
115
|
+
tokens_in=tokens_in,
|
|
116
|
+
tokens_out=tokens_out,
|
|
117
|
+
tokens_reasoning=thinking,
|
|
118
|
+
tokens_cached=cache_read,
|
|
119
|
+
tokens_cache_write=cache_write,
|
|
120
|
+
latency_ms=latency_ms,
|
|
121
|
+
cost_usd=cost,
|
|
122
|
+
request_id=getattr(message, "_request_id", None) or getattr(message, "id", None),
|
|
123
|
+
stop_reason=stop,
|
|
124
|
+
refusal=stop == "refusal",
|
|
125
|
+
batch=batch,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
async def complete(self, req: CompletionRequest) -> RawCompletion:
|
|
129
|
+
params = self.build_params(req)
|
|
130
|
+
t0 = time.perf_counter()
|
|
131
|
+
message = await self._paced(lambda: self.client.messages.create(**params))
|
|
132
|
+
return self.parse(message, req, int((time.perf_counter() - t0) * 1000))
|
|
133
|
+
|
|
134
|
+
async def submit_batch(self, reqs: list[BatchRequest]) -> str:
|
|
135
|
+
check_unique_ids(reqs)
|
|
136
|
+
requests = [
|
|
137
|
+
{"custom_id": r.custom_id, "params": self.build_params(r.request)} for r in reqs
|
|
138
|
+
]
|
|
139
|
+
batch = await self._paced(lambda: self.client.messages.batches.create(requests=requests))
|
|
140
|
+
return str(batch.id)
|
|
141
|
+
|
|
142
|
+
async def collect_batch(
|
|
143
|
+
self, batch_id: str, reqs: Mapping[str, CompletionRequest]
|
|
144
|
+
) -> list[BatchResult] | None:
|
|
145
|
+
status = await with_retries(
|
|
146
|
+
lambda: self.client.messages.batches.retrieve(batch_id), **self._retry
|
|
147
|
+
)
|
|
148
|
+
if status.processing_status != "ended":
|
|
149
|
+
return None
|
|
150
|
+
results = self.client.messages.batches.results(batch_id)
|
|
151
|
+
if inspect.isawaitable(results):
|
|
152
|
+
results = await results
|
|
153
|
+
out: list[BatchResult] = []
|
|
154
|
+
seen: set[str] = set()
|
|
155
|
+
async for item in results:
|
|
156
|
+
cid = item.custom_id
|
|
157
|
+
seen.add(cid)
|
|
158
|
+
if cid not in reqs:
|
|
159
|
+
continue # not ours; results arrive in any order and are keyed by custom_id
|
|
160
|
+
kind = item.result.type
|
|
161
|
+
if kind == "succeeded":
|
|
162
|
+
out.append(
|
|
163
|
+
BatchResult(
|
|
164
|
+
custom_id=cid,
|
|
165
|
+
completion=self.parse(
|
|
166
|
+
item.result.message, reqs[cid], latency_ms=0, batch=True
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
else:
|
|
171
|
+
err = getattr(item.result, "error", None)
|
|
172
|
+
# SDK shape: result.error is an ErrorResponse whose .error carries the type.
|
|
173
|
+
detail = getattr(getattr(err, "error", None), "type", None) or getattr(
|
|
174
|
+
err, "type", None
|
|
175
|
+
)
|
|
176
|
+
out.append(BatchResult(custom_id=cid, error=f"{kind}:{detail}" if detail else kind))
|
|
177
|
+
for cid in sorted(set(reqs) - seen):
|
|
178
|
+
out.append(BatchResult(custom_id=cid, error="missing_from_results"))
|
|
179
|
+
return out
|
evalkit/adapters/base.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""The adapter interface (ADP-01/02/08/09): every provider looks the same to EvalKit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.metadata
|
|
6
|
+
import itertools
|
|
7
|
+
import warnings
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
from collections.abc import Callable, Mapping
|
|
10
|
+
from typing import Any, ClassVar, Literal, TypeVar
|
|
11
|
+
|
|
12
|
+
from pydantic import Field, model_validator
|
|
13
|
+
|
|
14
|
+
from evalkit.schemas._base import (
|
|
15
|
+
FrozenMap,
|
|
16
|
+
JsonValue,
|
|
17
|
+
Model,
|
|
18
|
+
Name,
|
|
19
|
+
NonBlank,
|
|
20
|
+
NonNegFloat,
|
|
21
|
+
NonNegInt,
|
|
22
|
+
PosInt,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
ALLOWED_PARAMS = frozenset({"effort"}) # no temperature/top_p/top_k (D-22)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CompletionRequest(Model):
|
|
29
|
+
model: Name # alias from config, e.g. "sonnet"
|
|
30
|
+
model_id: Name # provider model id, e.g. "claude-sonnet-5-5"
|
|
31
|
+
prompt: str
|
|
32
|
+
system: str | None = None
|
|
33
|
+
max_output_tokens: PosInt = 1024
|
|
34
|
+
params: FrozenMap[str] = Field(default_factory=dict)
|
|
35
|
+
json_schema: FrozenMap[JsonValue] | None = None # structured output (D-24)
|
|
36
|
+
# Optional multi-turn conversation (an agent's history): alternating user/assistant
|
|
37
|
+
# turns, starting and ending with a user turn. When set, providers send these turns
|
|
38
|
+
# instead of `prompt`, and `prompt` must equal `render_turns(turns)` so caching keys see
|
|
39
|
+
# the same text. Each closed turn is a cacheable prefix: `cache` asks the provider to cache
|
|
40
|
+
# it (Anthropic automatic caching; OpenAI caches long prefixes on its own). Prefixes inside
|
|
41
|
+
# one message do not cache (measured, D-51).
|
|
42
|
+
turns: tuple[tuple[Literal["user", "assistant"], str], ...] | None = None
|
|
43
|
+
cache: bool = False
|
|
44
|
+
|
|
45
|
+
@model_validator(mode="after")
|
|
46
|
+
def _turns_valid(self) -> CompletionRequest:
|
|
47
|
+
if self.turns is None:
|
|
48
|
+
return self
|
|
49
|
+
roles = [r for r, _ in self.turns]
|
|
50
|
+
if not roles or roles[0] != "user" or roles[-1] != "user":
|
|
51
|
+
raise ValueError("turns must start and end with a user turn")
|
|
52
|
+
if any(a == b for a, b in itertools.pairwise(roles)):
|
|
53
|
+
raise ValueError("turns must alternate between user and assistant")
|
|
54
|
+
if render_turns(self.turns) != self.prompt:
|
|
55
|
+
raise ValueError("prompt must equal render_turns(turns)")
|
|
56
|
+
return self
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def render_turns(turns: tuple[tuple[str, str], ...] | list[tuple[str, str]]) -> str:
|
|
60
|
+
"""One text for a conversation (the cache key and providers' fallback)."""
|
|
61
|
+
return "".join(f"<{role}>\n{text}\n</{role}>\n" for role, text in turns)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class RawCompletion(Model):
|
|
65
|
+
"""What a provider returned, before EvalKit parses the answer (A2.1 builds Completion)."""
|
|
66
|
+
|
|
67
|
+
model: Name
|
|
68
|
+
model_snapshot: str
|
|
69
|
+
params: FrozenMap[str]
|
|
70
|
+
raw_text: str
|
|
71
|
+
tokens_in: NonNegInt # whole prompt, including cached and cache-write tokens
|
|
72
|
+
tokens_out: NonNegInt # visible output only
|
|
73
|
+
tokens_reasoning: NonNegInt = 0 # thinking/reasoning tokens, billed as output
|
|
74
|
+
tokens_cached: NonNegInt = 0
|
|
75
|
+
tokens_cache_write: NonNegInt = 0
|
|
76
|
+
latency_ms: NonNegInt
|
|
77
|
+
cost_usd: NonNegFloat
|
|
78
|
+
request_id: str | None = None
|
|
79
|
+
stop_reason: str = ""
|
|
80
|
+
refusal: bool = False
|
|
81
|
+
batch: bool = False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class BatchRequest(Model):
|
|
85
|
+
custom_id: NonBlank
|
|
86
|
+
request: CompletionRequest
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class BatchResult(Model):
|
|
90
|
+
custom_id: NonBlank
|
|
91
|
+
completion: RawCompletion | None = None
|
|
92
|
+
error: str | None = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def check_params(req: CompletionRequest, *, require_effort: bool = True) -> None:
|
|
96
|
+
bad = set(req.params) - ALLOWED_PARAMS
|
|
97
|
+
if bad:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f"unsupported request params {sorted(bad)}: only {sorted(ALLOWED_PARAMS)} are "
|
|
100
|
+
"allowed (sampling parameters are rejected by current models, D-22)"
|
|
101
|
+
)
|
|
102
|
+
if require_effort and "effort" not in req.params:
|
|
103
|
+
raise ValueError(
|
|
104
|
+
f"{req.model}: set params.effort explicitly; per-model defaults differ (D-24)"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def check_unique_ids(reqs: list[BatchRequest]) -> None:
|
|
109
|
+
ids = [r.custom_id for r in reqs]
|
|
110
|
+
dupes = sorted({i for i in ids if ids.count(i) > 1})
|
|
111
|
+
if dupes:
|
|
112
|
+
raise ValueError(f"duplicate custom_id values in batch: {dupes}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class ModelAdapter(ABC):
|
|
116
|
+
provider: ClassVar[str]
|
|
117
|
+
|
|
118
|
+
@abstractmethod
|
|
119
|
+
async def complete(self, req: CompletionRequest) -> RawCompletion: ...
|
|
120
|
+
|
|
121
|
+
@abstractmethod
|
|
122
|
+
async def submit_batch(self, reqs: list[BatchRequest]) -> str: ...
|
|
123
|
+
|
|
124
|
+
@abstractmethod
|
|
125
|
+
async def collect_batch(
|
|
126
|
+
self, batch_id: str, reqs: Mapping[str, CompletionRequest]
|
|
127
|
+
) -> list[BatchResult] | None:
|
|
128
|
+
"""None while the batch is still running; otherwise one result per custom_id."""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
_REGISTRY: dict[str, type[ModelAdapter]] = {}
|
|
132
|
+
ENTRY_POINT_GROUP = "evalkit.adapters"
|
|
133
|
+
_BUILTIN = frozenset({"anthropic", "openai", "openai_compatible"})
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
A = TypeVar("A", bound=type["ModelAdapter"])
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def register(name: str) -> Callable[[A], A]:
|
|
140
|
+
def deco(cls: A) -> A:
|
|
141
|
+
_REGISTRY[name] = cls
|
|
142
|
+
return cls
|
|
143
|
+
|
|
144
|
+
return deco
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def discover() -> dict[str, type[ModelAdapter]]:
|
|
148
|
+
"""Built-in adapters plus any registered by other packages under `evalkit.adapters`."""
|
|
149
|
+
from evalkit.adapters import anthropic as _a # noqa: F401 - registers on import
|
|
150
|
+
from evalkit.adapters import openai as _o # noqa: F401
|
|
151
|
+
|
|
152
|
+
for ep in importlib.metadata.entry_points(group=ENTRY_POINT_GROUP):
|
|
153
|
+
if ep.name in _BUILTIN:
|
|
154
|
+
warnings.warn(f"plugin adapter {ep.name!r} ignored: the name is built in", stacklevel=2)
|
|
155
|
+
continue
|
|
156
|
+
if ep.name in _REGISTRY:
|
|
157
|
+
continue
|
|
158
|
+
try:
|
|
159
|
+
cls = ep.load()
|
|
160
|
+
except Exception as exc: # a broken plugin must not take down the built-ins
|
|
161
|
+
warnings.warn(f"plugin adapter {ep.name!r} failed to load: {exc}", stacklevel=2)
|
|
162
|
+
continue
|
|
163
|
+
if not (isinstance(cls, type) and issubclass(cls, ModelAdapter)):
|
|
164
|
+
warnings.warn(
|
|
165
|
+
f"plugin {ep.name!r} is not a ModelAdapter subclass; ignored", stacklevel=2
|
|
166
|
+
)
|
|
167
|
+
continue
|
|
168
|
+
_REGISTRY[ep.name] = cls
|
|
169
|
+
return dict(_REGISTRY)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def get_adapter(name: str, **kwargs: Any) -> ModelAdapter:
|
|
173
|
+
adapters = discover()
|
|
174
|
+
if name not in adapters:
|
|
175
|
+
raise KeyError(f"unknown adapter {name!r}; available: {sorted(adapters)}")
|
|
176
|
+
return adapters[name](**kwargs)
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""OpenAI Chat Completions and Batch adapter (ADP-02, ADP-03), plus OpenAI-compatible
|
|
2
|
+
servers such as Ollama, vLLM or LM Studio (ADP-08).
|
|
3
|
+
|
|
4
|
+
- Effort maps to `reasoning_effort` (D-24); no sampling parameters (D-22).
|
|
5
|
+
- JSON via `response_format` json_schema (strict). `completion_tokens` includes reasoning;
|
|
6
|
+
reasoning is reported separately as tokens_reasoning.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from evalkit.adapters.base import (
|
|
18
|
+
BatchRequest,
|
|
19
|
+
BatchResult,
|
|
20
|
+
CompletionRequest,
|
|
21
|
+
ModelAdapter,
|
|
22
|
+
RawCompletion,
|
|
23
|
+
check_params,
|
|
24
|
+
check_unique_ids,
|
|
25
|
+
register,
|
|
26
|
+
)
|
|
27
|
+
from evalkit.cost import Price, call_cost, load_prices, price_for
|
|
28
|
+
from evalkit.ratelimit import TokenBucket
|
|
29
|
+
from evalkit.retry import with_retries
|
|
30
|
+
from evalkit.runtime.canonical import to_jsonable
|
|
31
|
+
|
|
32
|
+
FAILED_BATCH = frozenset({"failed", "expired", "cancelled"})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _get(obj: Any, name: str, default: Any = None) -> Any:
|
|
36
|
+
if isinstance(obj, Mapping):
|
|
37
|
+
return obj.get(name, default)
|
|
38
|
+
return getattr(obj, name, default)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@register("openai")
|
|
42
|
+
class OpenAIAdapter(ModelAdapter):
|
|
43
|
+
provider = "openai"
|
|
44
|
+
free_if_unknown = False
|
|
45
|
+
|
|
46
|
+
require_effort = True
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
client: Any = None,
|
|
51
|
+
prices: dict[str, Price] | None = None,
|
|
52
|
+
bucket: TokenBucket | None = None,
|
|
53
|
+
**retry: Any,
|
|
54
|
+
):
|
|
55
|
+
self._client = client
|
|
56
|
+
self._prices = prices if prices is not None else load_prices()
|
|
57
|
+
self._bucket = bucket # optional requests-per-second pacing (ADP-07)
|
|
58
|
+
self._retry = retry
|
|
59
|
+
|
|
60
|
+
async def _paced(self, call: Any) -> Any:
|
|
61
|
+
if self._bucket is not None:
|
|
62
|
+
await self._bucket.acquire()
|
|
63
|
+
return await with_retries(call, **self._retry)
|
|
64
|
+
|
|
65
|
+
def _make_client(self) -> Any:
|
|
66
|
+
import openai # optional dependency, imported only when used
|
|
67
|
+
|
|
68
|
+
return openai.AsyncOpenAI(max_retries=0)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def client(self) -> Any:
|
|
72
|
+
if self._client is None:
|
|
73
|
+
self._client = self._make_client()
|
|
74
|
+
return self._client
|
|
75
|
+
|
|
76
|
+
def build_params(self, req: CompletionRequest) -> dict[str, Any]:
|
|
77
|
+
check_params(req, require_effort=self.require_effort)
|
|
78
|
+
messages: list[dict[str, str]] = []
|
|
79
|
+
if req.system:
|
|
80
|
+
messages.append({"role": "system", "content": req.system})
|
|
81
|
+
if req.turns:
|
|
82
|
+
messages.extend({"role": r, "content": t} for r, t in req.turns)
|
|
83
|
+
else:
|
|
84
|
+
messages.append({"role": "user", "content": req.prompt})
|
|
85
|
+
params: dict[str, Any] = {
|
|
86
|
+
"model": req.model_id,
|
|
87
|
+
"messages": messages,
|
|
88
|
+
"max_completion_tokens": req.max_output_tokens,
|
|
89
|
+
}
|
|
90
|
+
if "effort" in req.params:
|
|
91
|
+
params["reasoning_effort"] = req.params["effort"]
|
|
92
|
+
if req.json_schema is not None:
|
|
93
|
+
params["response_format"] = {
|
|
94
|
+
"type": "json_schema",
|
|
95
|
+
"json_schema": {
|
|
96
|
+
"name": "answer",
|
|
97
|
+
"schema": to_jsonable(req.json_schema),
|
|
98
|
+
"strict": True,
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
return params
|
|
102
|
+
|
|
103
|
+
def parse(
|
|
104
|
+
self, resp: Any, req: CompletionRequest, latency_ms: int, *, batch: bool = False
|
|
105
|
+
) -> RawCompletion:
|
|
106
|
+
choice = _get(resp, "choices")[0]
|
|
107
|
+
msg = _get(choice, "message")
|
|
108
|
+
text = _get(msg, "content") or ""
|
|
109
|
+
usage = _get(resp, "usage")
|
|
110
|
+
prompt = _get(usage, "prompt_tokens", 0) or 0
|
|
111
|
+
completion = _get(usage, "completion_tokens", 0) or 0
|
|
112
|
+
reasoning = _get(_get(usage, "completion_tokens_details") or {}, "reasoning_tokens", 0) or 0
|
|
113
|
+
cached = _get(_get(usage, "prompt_tokens_details") or {}, "cached_tokens", 0) or 0
|
|
114
|
+
tokens_out = max(completion - reasoning, 0)
|
|
115
|
+
price = price_for(self._prices, req.model_id, free_if_unknown=self.free_if_unknown)
|
|
116
|
+
cost = call_cost(
|
|
117
|
+
price,
|
|
118
|
+
tokens_in=prompt,
|
|
119
|
+
tokens_out=tokens_out,
|
|
120
|
+
tokens_reasoning=reasoning,
|
|
121
|
+
tokens_cached=cached,
|
|
122
|
+
batch=batch,
|
|
123
|
+
)
|
|
124
|
+
refusal = bool(_get(msg, "refusal"))
|
|
125
|
+
stop = _get(choice, "finish_reason") or ""
|
|
126
|
+
return RawCompletion(
|
|
127
|
+
model=req.model,
|
|
128
|
+
model_snapshot=_get(resp, "model") or "",
|
|
129
|
+
params=dict(req.params),
|
|
130
|
+
raw_text=text,
|
|
131
|
+
tokens_in=prompt,
|
|
132
|
+
tokens_out=tokens_out,
|
|
133
|
+
tokens_reasoning=reasoning,
|
|
134
|
+
tokens_cached=cached,
|
|
135
|
+
latency_ms=latency_ms,
|
|
136
|
+
cost_usd=cost,
|
|
137
|
+
request_id=_get(resp, "_request_id") or _get(resp, "id"),
|
|
138
|
+
stop_reason="refusal" if refusal else stop,
|
|
139
|
+
refusal=refusal,
|
|
140
|
+
batch=batch,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
async def complete(self, req: CompletionRequest) -> RawCompletion:
|
|
144
|
+
params = self.build_params(req)
|
|
145
|
+
t0 = time.perf_counter()
|
|
146
|
+
resp = await self._paced(lambda: self.client.chat.completions.create(**params))
|
|
147
|
+
return self.parse(resp, req, int((time.perf_counter() - t0) * 1000))
|
|
148
|
+
|
|
149
|
+
async def submit_batch(self, reqs: list[BatchRequest]) -> str:
|
|
150
|
+
check_unique_ids(reqs)
|
|
151
|
+
lines = [
|
|
152
|
+
json.dumps(
|
|
153
|
+
{
|
|
154
|
+
"custom_id": r.custom_id,
|
|
155
|
+
"method": "POST",
|
|
156
|
+
"url": "/v1/chat/completions",
|
|
157
|
+
"body": self.build_params(r.request),
|
|
158
|
+
},
|
|
159
|
+
ensure_ascii=False,
|
|
160
|
+
)
|
|
161
|
+
for r in reqs
|
|
162
|
+
]
|
|
163
|
+
payload = ("\n".join(lines) + "\n").encode("utf-8")
|
|
164
|
+
upload = await with_retries(
|
|
165
|
+
lambda: self.client.files.create(
|
|
166
|
+
file=("evalkit-batch.jsonl", payload), purpose="batch"
|
|
167
|
+
),
|
|
168
|
+
**self._retry,
|
|
169
|
+
)
|
|
170
|
+
batch = await self._paced(
|
|
171
|
+
lambda: self.client.batches.create(
|
|
172
|
+
input_file_id=upload.id, endpoint="/v1/chat/completions", completion_window="24h"
|
|
173
|
+
),
|
|
174
|
+
)
|
|
175
|
+
return str(batch.id)
|
|
176
|
+
|
|
177
|
+
async def collect_batch(
|
|
178
|
+
self, batch_id: str, reqs: Mapping[str, CompletionRequest]
|
|
179
|
+
) -> list[BatchResult] | None:
|
|
180
|
+
batch = await with_retries(lambda: self.client.batches.retrieve(batch_id), **self._retry)
|
|
181
|
+
if batch.status not in FAILED_BATCH and batch.status != "completed":
|
|
182
|
+
return None # validating, in_progress, finalizing, cancelling
|
|
183
|
+
# Expired or cancelled batches can still hold finished, billed results: read them.
|
|
184
|
+
missing_error = (
|
|
185
|
+
"missing_from_results" if batch.status == "completed" else f"batch_{batch.status}"
|
|
186
|
+
)
|
|
187
|
+
lines: list[str] = []
|
|
188
|
+
for file_id in (batch.output_file_id, getattr(batch, "error_file_id", None)):
|
|
189
|
+
if file_id:
|
|
190
|
+
content = await with_retries(
|
|
191
|
+
lambda f=file_id: self.client.files.content(f), **self._retry
|
|
192
|
+
)
|
|
193
|
+
lines += [ln for ln in content.text.splitlines() if ln.strip()]
|
|
194
|
+
out: list[BatchResult] = []
|
|
195
|
+
seen: set[str] = set()
|
|
196
|
+
for line in lines:
|
|
197
|
+
try:
|
|
198
|
+
row = json.loads(line)
|
|
199
|
+
except ValueError:
|
|
200
|
+
continue # a damaged line; its request is reported missing below
|
|
201
|
+
cid = row.get("custom_id")
|
|
202
|
+
if cid not in reqs or cid in seen:
|
|
203
|
+
continue
|
|
204
|
+
seen.add(cid)
|
|
205
|
+
response = row.get("response") or {}
|
|
206
|
+
if row.get("error") or response.get("status_code") != 200:
|
|
207
|
+
err = (row.get("error") or {}).get("code") or f"http_{response.get('status_code')}"
|
|
208
|
+
out.append(BatchResult(custom_id=cid, error=str(err)))
|
|
209
|
+
else:
|
|
210
|
+
out.append(
|
|
211
|
+
BatchResult(
|
|
212
|
+
custom_id=cid,
|
|
213
|
+
completion=self.parse(
|
|
214
|
+
response["body"], reqs[cid], latency_ms=0, batch=True
|
|
215
|
+
),
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
for cid in sorted(set(reqs) - seen):
|
|
219
|
+
out.append(BatchResult(custom_id=cid, error=missing_error))
|
|
220
|
+
return out
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@register("openai_compatible")
|
|
224
|
+
class OpenAICompatibleAdapter(OpenAIAdapter):
|
|
225
|
+
"""Any server that speaks the OpenAI Chat Completions API (ADP-08). Models missing from
|
|
226
|
+
prices.yaml are costed at $0 with a warning, since local models are usually free."""
|
|
227
|
+
|
|
228
|
+
provider = "openai_compatible"
|
|
229
|
+
free_if_unknown = True
|
|
230
|
+
require_effort = False # local models often have no reasoning effort setting
|
|
231
|
+
|
|
232
|
+
def __init__(
|
|
233
|
+
self,
|
|
234
|
+
base_url: str,
|
|
235
|
+
api_key_env: str | None = None,
|
|
236
|
+
client: Any = None,
|
|
237
|
+
prices: dict[str, Price] | None = None,
|
|
238
|
+
**retry: Any,
|
|
239
|
+
):
|
|
240
|
+
if not base_url.startswith(("http://", "https://")):
|
|
241
|
+
raise ValueError(f"base_url must start with http:// or https://, got {base_url!r}")
|
|
242
|
+
super().__init__(client=client, prices=prices, **retry)
|
|
243
|
+
self.base_url = base_url
|
|
244
|
+
self.api_key_env = api_key_env
|
|
245
|
+
|
|
246
|
+
def _make_client(self) -> Any:
|
|
247
|
+
import openai
|
|
248
|
+
|
|
249
|
+
if self.api_key_env:
|
|
250
|
+
key = os.environ.get(self.api_key_env)
|
|
251
|
+
if not key:
|
|
252
|
+
raise ValueError(f"environment variable {self.api_key_env} is not set")
|
|
253
|
+
else:
|
|
254
|
+
key = "not-needed" # servers such as Ollama ignore the key
|
|
255
|
+
return openai.AsyncOpenAI(base_url=self.base_url, api_key=key, max_retries=0)
|
|
File without changes
|