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.
Files changed (119) hide show
  1. evalkit/__init__.py +5 -0
  2. evalkit/adapters/__init__.py +25 -0
  3. evalkit/adapters/anthropic.py +179 -0
  4. evalkit/adapters/base.py +176 -0
  5. evalkit/adapters/openai.py +255 -0
  6. evalkit/agents/__init__.py +0 -0
  7. evalkit/agents/aggregate.py +414 -0
  8. evalkit/agents/db.py +475 -0
  9. evalkit/agents/loop.py +473 -0
  10. evalkit/agents/runner.py +352 -0
  11. evalkit/agents/sandbox.py +418 -0
  12. evalkit/agents/score.py +94 -0
  13. evalkit/agents/simuser.py +470 -0
  14. evalkit/agents/tasks/an-01.yaml +22 -0
  15. evalkit/agents/tasks/an-02.yaml +29 -0
  16. evalkit/agents/tasks/an-03.yaml +26 -0
  17. evalkit/agents/tasks/an-04.yaml +27 -0
  18. evalkit/agents/tasks/an-05.yaml +41 -0
  19. evalkit/agents/tasks/an-06.yaml +45 -0
  20. evalkit/agents/tasks/an-07.yaml +30 -0
  21. evalkit/agents/tasks/an-08.yaml +23 -0
  22. evalkit/agents/tasks/an-09.yaml +42 -0
  23. evalkit/agents/tasks/an-10.yaml +23 -0
  24. evalkit/agents/tasks/an-11.yaml +31 -0
  25. evalkit/agents/tasks/an-12.yaml +32 -0
  26. evalkit/agents/tasks/an-13.yaml +32 -0
  27. evalkit/agents/tasks/an-14.yaml +27 -0
  28. evalkit/agents/tasks/an-15.yaml +30 -0
  29. evalkit/agents/tasks/an-16.yaml +22 -0
  30. evalkit/agents/tasks/at-01.yaml +318 -0
  31. evalkit/agents/tasks/at-02.yaml +19 -0
  32. evalkit/agents/tasks/at-03.yaml +17 -0
  33. evalkit/agents/tasks/at-04.yaml +318 -0
  34. evalkit/agents/tasks/at-05.yaml +19 -0
  35. evalkit/agents/tasks/at-06.yaml +17 -0
  36. evalkit/agents/tasks/at-07.yaml +318 -0
  37. evalkit/agents/tasks/at-08.yaml +19 -0
  38. evalkit/agents/tasks/at-09.yaml +16 -0
  39. evalkit/agents/tasks/at-10.yaml +317 -0
  40. evalkit/agents/tasks/at-11.yaml +18 -0
  41. evalkit/agents/tasks/at-12.yaml +16 -0
  42. evalkit/agents/tasks/at-13.yaml +317 -0
  43. evalkit/agents/tasks/at-14.yaml +18 -0
  44. evalkit/agents/tasks/at-15.yaml +16 -0
  45. evalkit/agents/tasks/at-16.yaml +317 -0
  46. evalkit/agents/tasks.py +184 -0
  47. evalkit/cli.py +739 -0
  48. evalkit/config.py +75 -0
  49. evalkit/cost.py +239 -0
  50. evalkit/datasets/__init__.py +15 -0
  51. evalkit/datasets/local.py +146 -0
  52. evalkit/datasets/pubmedqa.py +130 -0
  53. evalkit/datasets/scifact.py +159 -0
  54. evalkit/demo/__init__.py +15 -0
  55. evalkit/demo/engine.py +486 -0
  56. evalkit/demo/guard.py +282 -0
  57. evalkit/demo/page.py +377 -0
  58. evalkit/demo/pubmed.py +66 -0
  59. evalkit/env.py +42 -0
  60. evalkit/examples/__init__.py +0 -0
  61. evalkit/examples/example_run/cases.jsonl +12 -0
  62. evalkit/examples/example_run/records.jsonl +36 -0
  63. evalkit/examples/example_run/run.json +35 -0
  64. evalkit/examples/example_run/summary.json +325 -0
  65. evalkit/onboarding.py +280 -0
  66. evalkit/pipeline/__init__.py +1 -0
  67. evalkit/pipeline/aggregate.py +382 -0
  68. evalkit/pipeline/answer.py +68 -0
  69. evalkit/pipeline/calibrate.py +560 -0
  70. evalkit/pipeline/extract.py +398 -0
  71. evalkit/pipeline/generate.py +233 -0
  72. evalkit/pipeline/judge.py +832 -0
  73. evalkit/pipeline/labeller.html +53 -0
  74. evalkit/pipeline/labeller.py +201 -0
  75. evalkit/prices.yaml +24 -0
  76. evalkit/py.typed +0 -0
  77. evalkit/ratelimit.py +46 -0
  78. evalkit/report/__init__.py +5 -0
  79. evalkit/report/agents.py +242 -0
  80. evalkit/report/html.py +502 -0
  81. evalkit/report/readme.py +205 -0
  82. evalkit/report/svg.py +254 -0
  83. evalkit/retry.py +62 -0
  84. evalkit/run.py +655 -0
  85. evalkit/runtime/__init__.py +20 -0
  86. evalkit/runtime/canonical.py +42 -0
  87. evalkit/runtime/code_versions.json +26 -0
  88. evalkit/runtime/executor.py +332 -0
  89. evalkit/runtime/node.py +70 -0
  90. evalkit/runtime/store.py +152 -0
  91. evalkit/runtime/versions.py +164 -0
  92. evalkit/runtime/writer.py +63 -0
  93. evalkit/sampling.py +52 -0
  94. evalkit/schemas/__init__.py +58 -0
  95. evalkit/schemas/_base.py +101 -0
  96. evalkit/schemas/agent.py +127 -0
  97. evalkit/schemas/benchmark.py +128 -0
  98. evalkit/schemas/calibration.py +51 -0
  99. evalkit/schemas/config.py +86 -0
  100. evalkit/schemas/summary.py +173 -0
  101. evalkit/scorers/__init__.py +30 -0
  102. evalkit/scorers/attack.py +81 -0
  103. evalkit/scorers/base.py +164 -0
  104. evalkit/scorers/deterministic.py +52 -0
  105. evalkit/scorers/faithfulness.py +180 -0
  106. evalkit/scorers/retrieval.py +83 -0
  107. evalkit/scorers/sql.py +306 -0
  108. evalkit/smoke.py +137 -0
  109. evalkit/stats.py +117 -0
  110. evalkit/tasks/__init__.py +32 -0
  111. evalkit/tasks/pubmedqa.yaml +25 -0
  112. evalkit/tasks/scifact.yaml +27 -0
  113. evalkit/text/__init__.py +5 -0
  114. evalkit/text/sentences.py +138 -0
  115. evalkit_harness-0.1.0.dist-info/METADATA +70 -0
  116. evalkit_harness-0.1.0.dist-info/RECORD +119 -0
  117. evalkit_harness-0.1.0.dist-info/WHEEL +4 -0
  118. evalkit_harness-0.1.0.dist-info/entry_points.txt +2 -0
  119. evalkit_harness-0.1.0.dist-info/licenses/LICENSE +21 -0
evalkit/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """EvalKit: evaluate your own task across Claude and OpenAI models."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["__version__"]
@@ -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
@@ -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