Add model layer: async ModelAdapter (openai_compatible + mock) returning structured ModelOutput(text,tool_calls,usage), Deployer registry (vllm/sglang docker-pinned via models.yaml, external), async run_eval generate->score, CLI --model, agent-ready SampleResult.trajectory/env_state, tests
This commit is contained in:
parent
4a15f80897
commit
6b3bb330c7
50
README.md
50
README.md
@ -1,9 +1,10 @@
|
|||||||
# EvalHarness
|
# EvalHarness
|
||||||
|
|
||||||
A plugin-based LLM/agent evaluation harness. **Currently: the data layer +
|
A plugin-based LLM/agent evaluation harness. **Currently: data + evaluation +
|
||||||
the evaluation layer** (datasets & eval recipes as plugins, lazy
|
model layers** (datasets/eval-recipes/model-adapters/deployers as plugins,
|
||||||
materialization cache, official-aligned scorers, report artifacts & console
|
lazy materialization cache, official-aligned scorers, async generation,
|
||||||
visualization). Sandbox/model/tool/skill layers land one at a time.
|
report artifacts & console visualization). Sandbox/agent/tool/skill layers
|
||||||
|
land one at a time.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@ -147,6 +148,36 @@ Per-sample results keep `raw_prediction` + extraction note + score details;
|
|||||||
and re-running `evaluate()` re-scores the same predictions — the model is
|
and re-running `evaluate()` re-scores the same predictions — the model is
|
||||||
never re-queried.
|
never re-queried.
|
||||||
|
|
||||||
|
## Model layer (calling + deploying, separate plugins on purpose)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# generate + score in one command (async, concurrent)
|
||||||
|
evalharness eval run gsm8k --model mock:boxed --limit 100 # offline pipeline check
|
||||||
|
evalharness eval run gsm8k --model openai/http://gpu03:8000/v1?qwen3-8b
|
||||||
|
evalharness eval run hle --model openai/...?qwen3-8b --judge openai/...?gpt-4o
|
||||||
|
# future: --model deploy:vllm/qwen3-8b (Deployer pulls a pinned docker env)
|
||||||
|
```
|
||||||
|
|
||||||
|
Model spec grammar (plain strings):
|
||||||
|
|
||||||
|
| spec | meaning |
|
||||||
|
|---|---|
|
||||||
|
| `mock` / `mock:boxed` / `mock:tool` | offline adapter (echo / oracle-boxed / tool-call) |
|
||||||
|
| `openai/<api_base>?<model_id>` | any OpenAI-protocol endpoint: vllm, sglang, lmdeploy, ollama, cloud APIs |
|
||||||
|
| `deploy:<engine>/<model>` | Deployer resolves the endpoint first (vllm/sglang: pinned docker image; external: models.yaml) |
|
||||||
|
|
||||||
|
Design:
|
||||||
|
|
||||||
|
- **ModelAdapter = how to call** (protocol). All adapters are `async` and
|
||||||
|
return structured `ModelOutput(text, tool_calls, usage)` — the hinge the
|
||||||
|
future agent loops hang on; single-turn recipes just read `.text`.
|
||||||
|
- **Deployer = how to run** (environment, separate lifecycle). Docker images
|
||||||
|
are pinned per model in `models.yaml`, so `vllm:v0.9.2` and `vllm:v0.6.6`
|
||||||
|
stacks coexist on one machine; `external` connects to existing endpoints.
|
||||||
|
- **Async boundary = waiting on the model**: `run_eval()` fans out calls with
|
||||||
|
a semaphore (default 32), collects raws + per-sample usage, then hands them
|
||||||
|
to the synchronous `evaluate()`. Data/scoring stay sync (fast, CPU/disk).
|
||||||
|
|
||||||
## Built-in datasets (28, official sources)
|
## Built-in datasets (28, official sources)
|
||||||
|
|
||||||
| Family | Datasets (source) |
|
| Family | Datasets (source) |
|
||||||
@ -257,6 +288,11 @@ EvalHarness/
|
|||||||
│ │ ├── loader.py # raw loading (local/HF/ModelScope native)
|
│ │ ├── loader.py # raw loading (local/HF/ModelScope native)
|
||||||
│ │ ├── dataset.py # Dataset: lazy materialize + cache + derived views
|
│ │ ├── dataset.py # Dataset: lazy materialize + cache + derived views
|
||||||
│ │ └── datasets/ # 28 built-in single-file dataset plugins
|
│ │ └── datasets/ # 28 built-in single-file dataset plugins
|
||||||
|
│ ├── model/ # ---- model layer ----
|
||||||
|
│ │ ├── output.py # ModelOutput/ToolCall/Usage (agent hinge)
|
||||||
|
│ │ ├── adapter.py # @register_adapter: openai_compatible / mock
|
||||||
|
│ │ ├── deployer.py # @register_deployer: vllm / sglang / external
|
||||||
|
│ │ └── runner.py # async run_eval(): generate -> evaluate
|
||||||
│ ├── eval/ # ---- evaluation layer ----
|
│ ├── eval/ # ---- evaluation layer ----
|
||||||
│ │ ├── record.py # SampleResult / EvalReport artifacts
|
│ │ ├── record.py # SampleResult / EvalReport artifacts
|
||||||
│ │ ├── extractor.py # answer-extraction primitives (+cascades)
|
│ │ ├── extractor.py # answer-extraction primitives (+cascades)
|
||||||
@ -279,7 +315,11 @@ EvalHarness/
|
|||||||
- [x] Data layer (28 dataset plugins, lazy cache, native HF/ModelScope loaders)
|
- [x] Data layer (28 dataset plugins, lazy cache, native HF/ModelScope loaders)
|
||||||
- [x] Evaluation layer (extract/score/aggregate plugins, official scorers, recipes)
|
- [x] Evaluation layer (extract/score/aggregate plugins, official scorers, recipes)
|
||||||
- [x] Visualization (console/markdown renderers over report artifacts)
|
- [x] Visualization (console/markdown renderers over report artifacts)
|
||||||
- [ ] Model layer (ModelAdapter: unified URL-based invocation; wires llm_judge)
|
- [x] Model layer (async ModelAdapter openai_compatible+mock, ModelOutput
|
||||||
|
with tool_calls, Deployer registry vllm/sglang/external + models.yaml
|
||||||
|
env pinning, run_eval generate->score)
|
||||||
|
- [ ] Agent layer (loops: single_turn fast path today, react/plan_execute;
|
||||||
|
environments: tau2 user-sim, swe docker; SampleResult.trajectory ready)
|
||||||
- [ ] Sandbox layer (materialize `Sample.sandbox`: lazy per-instance image
|
- [ ] Sandbox layer (materialize `Sample.sandbox`: lazy per-instance image
|
||||||
pull, refcounted image unload, container lifecycle; `requires` gating)
|
pull, refcounted image unload, container lifecycle; `requires` gating)
|
||||||
- [ ] Tool layer (data filter/synthesis/dedup/export; Dataset in, Dataset out)
|
- [ ] Tool layer (data filter/synthesis/dedup/export; Dataset in, Dataset out)
|
||||||
|
|||||||
@ -108,18 +108,27 @@ def _cmd_eval_list(_args) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _cmd_eval_run(args) -> int:
|
def _cmd_eval_run(args) -> int:
|
||||||
from evalharness.eval import evaluate, get_eval
|
import asyncio
|
||||||
|
|
||||||
from evalharness.data import get_dataset
|
from evalharness.data import get_dataset
|
||||||
|
from evalharness.viz import render
|
||||||
|
|
||||||
ds = get_dataset(args.dataset, **_overrides(args))
|
ds = get_dataset(args.dataset, **_overrides(args))
|
||||||
|
if args.model: # generate + score in one go
|
||||||
|
from evalharness.model import run_eval
|
||||||
|
|
||||||
|
report = asyncio.run(run_eval(
|
||||||
|
ds, args.model, concurrency=args.concurrency, limit=args.limit,
|
||||||
|
judge_spec=args.judge))
|
||||||
|
else:
|
||||||
|
from evalharness.eval import evaluate
|
||||||
|
|
||||||
preds = [json.loads(line) for line in open(args.predictions, encoding='utf-8') if line.strip()]
|
preds = [json.loads(line) for line in open(args.predictions, encoding='utf-8') if line.strip()]
|
||||||
preds = [p.get('raw', p.get('prediction', '')) if isinstance(p, dict) else p for p in preds]
|
preds = [p.get('raw', p.get('prediction', '')) if isinstance(p, dict) else p for p in preds]
|
||||||
report = evaluate(ds, preds, model=args.model)
|
report = evaluate(ds, preds, model=args.model or 'preds')
|
||||||
if args.out:
|
if args.out:
|
||||||
report.save(args.out)
|
report.save(args.out)
|
||||||
print(f'saved -> {args.out}')
|
print(f'saved -> {args.out}')
|
||||||
from evalharness.viz import render
|
|
||||||
|
|
||||||
print(render(report, style=args.style))
|
print(render(report, style=args.style))
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@ -171,10 +180,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
p = esub.add_parser('list', help='list registered eval recipes')
|
p = esub.add_parser('list', help='list registered eval recipes')
|
||||||
p.set_defaults(func=_cmd_eval_list)
|
p.set_defaults(func=_cmd_eval_list)
|
||||||
|
|
||||||
p = esub.add_parser('run', help='score predictions against a dataset')
|
p = esub.add_parser('run', help='score predictions (file) or generate+score (--model)')
|
||||||
p.add_argument('dataset', help='dataset name (recipe auto-resolved)')
|
p.add_argument('dataset', help='dataset name (recipe auto-resolved)')
|
||||||
p.add_argument('predictions', help='jsonl: one raw string or {"raw": ...} per sample')
|
p.add_argument('predictions', nargs='?', help='jsonl: one raw string or {"raw": ...} per sample')
|
||||||
p.add_argument('--model', default='', help='model tag recorded in the report')
|
p.add_argument('--model', default='',
|
||||||
|
help="generate with model spec: mock | mock:boxed | "
|
||||||
|
"openai/http://host:8000/v1?model | deploy:vllm/model")
|
||||||
|
p.add_argument('--judge', default='', help='judge model spec for llm_judge recipes')
|
||||||
|
p.add_argument('--concurrency', type=int, default=32, help='parallel model calls (default 32)')
|
||||||
|
p.add_argument('--limit', type=int, help='evaluate only the first N samples')
|
||||||
p.add_argument('--out', help='save the EvalReport json here')
|
p.add_argument('--out', help='save the EvalReport json here')
|
||||||
p.add_argument('--style', default='text', help='result render style (text/md/radar/errors)')
|
p.add_argument('--style', default='text', help='result render style (text/md/radar/errors)')
|
||||||
_add_override_flags(p)
|
_add_override_flags(p)
|
||||||
|
|||||||
@ -36,6 +36,13 @@ class SampleResult(BaseModel):
|
|||||||
metadata: Dict[str, Any] = Field(default_factory=dict)
|
metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||||
error: str = '' # scorer/extractor exception (never silently dropped)
|
error: str = '' # scorer/extractor exception (never silently dropped)
|
||||||
|
|
||||||
|
# agent hinge: when a future AgentLoop produces multi-turn trajectories,
|
||||||
|
# they land here (list of ChatMessage dumps) + final environment state;
|
||||||
|
# env_reward scorers consume these instead of extracted text
|
||||||
|
trajectory: Optional[List[Dict[str, Any]]] = None
|
||||||
|
env_state: Optional[Dict[str, Any]] = None
|
||||||
|
usage: Optional[Dict[str, Any]] = None # tokens/cost/latency per sample
|
||||||
|
|
||||||
|
|
||||||
class EvalReport(BaseModel):
|
class EvalReport(BaseModel):
|
||||||
"""Aggregated artifact: what a viewer/visualizer consumes."""
|
"""Aggregated artifact: what a viewer/visualizer consumes."""
|
||||||
|
|||||||
@ -69,6 +69,12 @@ def evaluate(
|
|||||||
)
|
)
|
||||||
if isinstance(pred, dict) and pred.get('metadata'):
|
if isinstance(pred, dict) and pred.get('metadata'):
|
||||||
result.metadata.update(pred['metadata'])
|
result.metadata.update(pred['metadata'])
|
||||||
|
if isinstance(pred, dict) and pred.get('trajectory'):
|
||||||
|
result.trajectory = pred['trajectory']
|
||||||
|
if isinstance(pred, dict) and pred.get('env_state'):
|
||||||
|
result.env_state = pred['env_state']
|
||||||
|
if isinstance(pred, dict) and pred.get('usage'):
|
||||||
|
result.usage = pred['usage']
|
||||||
try:
|
try:
|
||||||
value, ok, note = extractor(raw, sample)
|
value, ok, note = extractor(raw, sample)
|
||||||
result.extracted_prediction = value
|
result.extracted_prediction = value
|
||||||
|
|||||||
45
evalharness/model/__init__.py
Normal file
45
evalharness/model/__init__.py
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
"""evalharness.model -- calling and deploying models.
|
||||||
|
|
||||||
|
Two plugin families, deliberately separate lifecycles:
|
||||||
|
ModelAdapter HOW to call (protocol): openai_compatible, mock, ...
|
||||||
|
Deployer HOW to run (environment): vllm, sglang, external (docker-pinned)
|
||||||
|
|
||||||
|
Model spec grammar (strings everywhere, no config ceremony):
|
||||||
|
mock / mock:boxed / mock:tool offline
|
||||||
|
openai/http://host:8000/v1?model_id any OpenAI-protocol endpoint (vllm, sglang, cloud)
|
||||||
|
deploy:vllm/qwen3-8b Deployer resolves endpoint (models.yaml pins env)
|
||||||
|
|
||||||
|
All adapters are async and return structured ModelOutput(text, tool_calls,
|
||||||
|
usage) -- the hinge the future agent loops hang on.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .adapter import (
|
||||||
|
ADAPTER_REGISTRY,
|
||||||
|
ModelAdapter,
|
||||||
|
OpenAICompatible,
|
||||||
|
MockAdapter,
|
||||||
|
register_adapter,
|
||||||
|
resolve_adapter,
|
||||||
|
parse_model_spec,
|
||||||
|
)
|
||||||
|
from .deployer import (
|
||||||
|
DEPLOYER_REGISTRY,
|
||||||
|
Deployer,
|
||||||
|
VLLMDeployer,
|
||||||
|
SGLangDeployer,
|
||||||
|
External,
|
||||||
|
deploy,
|
||||||
|
load_model_config,
|
||||||
|
register_deployer,
|
||||||
|
stop_all,
|
||||||
|
)
|
||||||
|
from .output import ModelOutput, ToolCall, Usage
|
||||||
|
from .runner import generate_predictions, run_eval
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'ModelAdapter', 'OpenAICompatible', 'MockAdapter', 'register_adapter',
|
||||||
|
'resolve_adapter', 'parse_model_spec', 'ADAPTER_REGISTRY',
|
||||||
|
'Deployer', 'VLLMDeployer', 'SGLangDeployer', 'External',
|
||||||
|
'register_deployer', 'deploy', 'stop_all', 'load_model_config', 'DEPLOYER_REGISTRY',
|
||||||
|
'ModelOutput', 'ToolCall', 'Usage', 'generate_predictions', 'run_eval',
|
||||||
|
]
|
||||||
227
evalharness/model/adapter.py
Normal file
227
evalharness/model/adapter.py
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
"""ModelAdapter: HOW to call a model (protocol level). Never deploys anything.
|
||||||
|
|
||||||
|
Registry + two built-ins:
|
||||||
|
openai_compatible -- /v1/chat/completions; covers vllm, sglang, lmdeploy,
|
||||||
|
ollama, tgi, and every OpenAI-protocol cloud API
|
||||||
|
mock -- offline deterministic/testing adapter
|
||||||
|
|
||||||
|
Model spec grammar (a single string, no config files needed for the common case):
|
||||||
|
openai/http://localhost:8000/v1?qwen3-8b adapter + api_base + model id
|
||||||
|
openai/https://api.openai.com/v1?gpt-4o (OPENAI_API_KEY read from env)
|
||||||
|
anthropic/claude-... (provider-native protocols later)
|
||||||
|
mock offline
|
||||||
|
deploy:vllm/qwen3-8b -> Deployer resolves to an endpoint,
|
||||||
|
then re-dispatches as openai/...
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from ..data.sample import ChatMessage
|
||||||
|
from ..eval.registry import EvalRegistry
|
||||||
|
from .output import ModelOutput, ToolCall, Usage
|
||||||
|
|
||||||
|
ADAPTER_REGISTRY = EvalRegistry('model adapter')
|
||||||
|
|
||||||
|
|
||||||
|
def register_adapter(name: str):
|
||||||
|
def decorator(cls):
|
||||||
|
ADAPTER_REGISTRY.register(name, cls)
|
||||||
|
return cls
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
class ModelAdapter:
|
||||||
|
"""Base class. Subclasses implement generate() (async, structured output)."""
|
||||||
|
|
||||||
|
name = 'base'
|
||||||
|
|
||||||
|
def __init__(self, model: str = '', api_base: str = '', api_key: str = '', **kwargs):
|
||||||
|
self.model = model
|
||||||
|
self.api_base = api_base.rstrip('/')
|
||||||
|
self.api_key = api_key
|
||||||
|
self.extra = kwargs
|
||||||
|
|
||||||
|
async def generate(
|
||||||
|
self,
|
||||||
|
messages: List[ChatMessage],
|
||||||
|
tools: Optional[List[Dict[str, Any]]] = None,
|
||||||
|
**gen_kwargs,
|
||||||
|
) -> ModelOutput:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'{type(self).__name__}(model={self.model!r}, base={self.api_base!r})'
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------- spec parsing / dispatch ---------------------------
|
||||||
|
|
||||||
|
_SPEC_RE = re.compile(r'^(?P<adapter>[a-z_]+)(/(?P<rest>.+))?$')
|
||||||
|
_DEPLOY_RE = re.compile(r'^deploy:(?P<deployer>[a-z0-9_-]+)/(?P<model>.+)$')
|
||||||
|
|
||||||
|
|
||||||
|
def parse_model_spec(spec: str) -> Dict[str, str]:
|
||||||
|
"""'openai/http://x:8000/v1?qwen' -> {adapter, api_base, model}."""
|
||||||
|
m = _SPEC_RE.match(spec.strip())
|
||||||
|
if not m:
|
||||||
|
raise ValueError(f'bad model spec: {spec!r} (expected adapter/rest?model)')
|
||||||
|
adapter = m.group('adapter')
|
||||||
|
rest = m.group('rest') or ''
|
||||||
|
api_base, _, model = rest.partition('?')
|
||||||
|
return {'adapter': adapter, 'api_base': api_base, 'model': model}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_adapter(spec: str, deploy_fn=None) -> ModelAdapter:
|
||||||
|
"""Spec -> adapter instance. deploy:... specs go through a Deployer first."""
|
||||||
|
m = _DEPLOY_RE.match(spec.strip())
|
||||||
|
if m:
|
||||||
|
if deploy_fn is None:
|
||||||
|
from .deployer import deploy as default_deploy
|
||||||
|
deploy_fn = default_deploy
|
||||||
|
endpoint = deploy_fn(m.group('deployer'), m.group('model'))
|
||||||
|
spec = f"openai/{endpoint['api_base']}?{endpoint['model']}"
|
||||||
|
parsed = parse_model_spec(spec)
|
||||||
|
cls = ADAPTER_REGISTRY.get(parsed['adapter'])
|
||||||
|
key = parsed.get('api_base') and _key_for(parsed['api_base'])
|
||||||
|
return cls(model=parsed['model'], api_base=parsed['api_base'], api_key=key)
|
||||||
|
|
||||||
|
|
||||||
|
def _key_for(api_base: str) -> str:
|
||||||
|
"""Best-effort API key by endpoint; explicit env always wins."""
|
||||||
|
for host_hint, var in (('api.openai.com', 'OPENAI_API_KEY'),
|
||||||
|
('anthropic.com', 'ANTHROPIC_API_KEY'),
|
||||||
|
('dashscope', 'DASHSCOPE_API_KEY'),
|
||||||
|
('bigmodel', 'ZAI_API_KEY')):
|
||||||
|
if host_hint in api_base:
|
||||||
|
return os.environ.get(var, '')
|
||||||
|
return os.environ.get('OPENAI_API_KEY', '')
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------- openai_compatible ---------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@register_adapter('openai')
|
||||||
|
class OpenAICompatible(ModelAdapter):
|
||||||
|
"""Async OpenAI chat-completions client with zero hard dependencies.
|
||||||
|
|
||||||
|
Uses httpx if installed (proper async); falls back to urllib in a thread
|
||||||
|
so the core install stays dependency-free.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = 'openai'
|
||||||
|
|
||||||
|
async def generate(self, messages, tools=None, **kw) -> ModelOutput:
|
||||||
|
payload = self._payload(messages, tools, kw)
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
if self.api_key:
|
||||||
|
headers['Authorization'] = f'Bearer {self.api_key}'
|
||||||
|
data = await self._post(f'{self.api_base}/chat/completions', payload, headers)
|
||||||
|
return self._parse(data)
|
||||||
|
|
||||||
|
def _payload(self, messages, tools, kw) -> Dict[str, Any]:
|
||||||
|
msgs = [{'role': m.role, 'content': m.content} for m in messages]
|
||||||
|
payload: Dict[str, Any] = {'model': self.model, 'messages': msgs}
|
||||||
|
if tools:
|
||||||
|
payload['tools'] = [
|
||||||
|
{'type': 'function', 'function': t} if 'function' not in t else t for t in tools
|
||||||
|
]
|
||||||
|
for k in ('temperature', 'max_tokens', 'top_p', 'stop', 'seed', 'response_format'):
|
||||||
|
if kw.get(k) is not None:
|
||||||
|
payload[k] = kw[k]
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def _parse(self, data: Dict[str, Any]) -> ModelOutput:
|
||||||
|
choice = (data.get('choices') or [{}])[0]
|
||||||
|
msg = choice.get('message') or {}
|
||||||
|
calls = []
|
||||||
|
for c in msg.get('tool_calls') or []:
|
||||||
|
fn = c.get('function') or {}
|
||||||
|
args = fn.get('arguments') or '{}'
|
||||||
|
try:
|
||||||
|
args_dict = json.loads(args)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
args_dict = {}
|
||||||
|
calls.append(ToolCall(id=c.get('id', ''), name=fn.get('name', ''),
|
||||||
|
arguments=args, arguments_dict=args_dict))
|
||||||
|
u = data.get('usage') or {}
|
||||||
|
usage = Usage(input_tokens=u.get('prompt_tokens', 0),
|
||||||
|
output_tokens=u.get('completion_tokens', 0),
|
||||||
|
total_tokens=u.get('total_tokens', 0),
|
||||||
|
finish_reason=choice.get('finish_reason', ''))
|
||||||
|
return ModelOutput(text=msg.get('content') or '', tool_calls=calls, usage=usage,
|
||||||
|
raw=data, model=data.get('model', self.model))
|
||||||
|
|
||||||
|
async def _post(self, url: str, payload: Dict, headers: Dict) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=self.extra.get('timeout', 600)) as client:
|
||||||
|
r = await client.post(url, json=payload, headers=headers)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
except ImportError:
|
||||||
|
import asyncio
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
def _sync():
|
||||||
|
req = urllib.request.Request(url, data=json.dumps(payload).encode(),
|
||||||
|
headers=headers, method='POST')
|
||||||
|
with urllib.request.urlopen(req, timeout=self.extra.get('timeout', 600)) as resp:
|
||||||
|
return json.loads(resp.read().decode())
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_sync)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------- mock ---------------------------
|
||||||
|
|
||||||
|
_MOCK_PATTERNS = (
|
||||||
|
(re.compile(r'\\boxed\{([^}]*)\}'), None), # echo any boxed target in the input
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@register_adapter('mock')
|
||||||
|
class MockAdapter(ModelAdapter):
|
||||||
|
"""Offline adapter for tests/CI/dev.
|
||||||
|
|
||||||
|
Modes (extra['mode']):
|
||||||
|
echo -- return the input text (default)
|
||||||
|
boxed -- return \\boxed{<target>} scraped from the last user message
|
||||||
|
(metadata['mock_target'] or first number found)
|
||||||
|
tool -- return one tool call named extra['tool_name']
|
||||||
|
const -- return extra['text']
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = 'mock'
|
||||||
|
|
||||||
|
async def generate(self, messages, tools=None, **kw) -> ModelOutput:
|
||||||
|
mode = self.extra.get('mode', 'echo')
|
||||||
|
if mode == 'const':
|
||||||
|
text = self.extra.get('text', 'mock')
|
||||||
|
elif mode == 'tool':
|
||||||
|
name = self.extra.get('tool_name', 'dummy_tool')
|
||||||
|
return ModelOutput(text='', tool_calls=[ToolCall(
|
||||||
|
name=name, arguments='{}', arguments_dict={})], model='mock')
|
||||||
|
else:
|
||||||
|
last = next((m.content for m in reversed(messages) if m.role == 'user'), '')
|
||||||
|
text = last
|
||||||
|
if mode == 'boxed':
|
||||||
|
# cheat-mode for pipeline verification: an oracle message
|
||||||
|
# (metadata-free) can't know the target, so the runner tags
|
||||||
|
# the message with 'mock_target' when samples carry one
|
||||||
|
target = None
|
||||||
|
for m in reversed(messages):
|
||||||
|
if m.role == 'user' and m.content.startswith('MOCKTARGET::'):
|
||||||
|
target = m.content[len('MOCKTARGET::'):]
|
||||||
|
break
|
||||||
|
if target is None:
|
||||||
|
m = _MOCK_PATTERNS[0][0].search(last)
|
||||||
|
target = m.group(1) if m else (re.findall(r'-?\d+\.?\d*', last) or ['0'])[-1]
|
||||||
|
text = f'The answer is \\boxed{{{target}}}.'
|
||||||
|
return ModelOutput(text=text, model='mock', usage=Usage(
|
||||||
|
input_tokens=1, output_tokens=1, total_tokens=2, finish_reason='stop'))
|
||||||
187
evalharness/model/deployer.py
Normal file
187
evalharness/model/deployer.py
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
"""Deployer: HOW to run a model (environment level). Never scores anything.
|
||||||
|
|
||||||
|
Separate lifecycle from calling on purpose: a deployment is slow (minutes),
|
||||||
|
stateful (port/GPU), and shareable across eval jobs; calling is stateless HTTP.
|
||||||
|
|
||||||
|
Built-ins:
|
||||||
|
vllm -- docker run vllm/vllm-openji:<tag> (image pin = environment pin;
|
||||||
|
override per-model via models.yaml so multiple versions coexist)
|
||||||
|
sglang -- docker run lmsysorg/sglang:<tag>
|
||||||
|
external -- nothing to do; endpoint already exists (default for cloud APIs)
|
||||||
|
|
||||||
|
Environment binding is DECLARATIVE (models.yaml), never code:
|
||||||
|
|
||||||
|
models:
|
||||||
|
qwen3-8b:
|
||||||
|
deployer: vllm
|
||||||
|
image: vllm/vllm-openai:v0.9.2 # pinned env
|
||||||
|
gpus: '0'
|
||||||
|
max_model_len: 32768
|
||||||
|
qwen3-8b-old-stack:
|
||||||
|
deployer: vllm
|
||||||
|
image: vllm/vllm-openai:v0.6.6.post1 # same model, different env, coexists
|
||||||
|
port: 8001
|
||||||
|
|
||||||
|
resolve('vllm', 'qwen3-8b') -> {'api_base': ..., 'model': ...}
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from ..eval.registry import EvalRegistry
|
||||||
|
|
||||||
|
DEPLOYER_REGISTRY = EvalRegistry('deployer')
|
||||||
|
|
||||||
|
|
||||||
|
def register_deployer(name: str):
|
||||||
|
def decorator(cls):
|
||||||
|
DEPLOYER_REGISTRY.register(name, cls)
|
||||||
|
return cls
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def _models_yaml_path() -> Path:
|
||||||
|
return Path(os.environ.get('EVALHARNESS_MODELS', 'models.yaml')).expanduser()
|
||||||
|
|
||||||
|
|
||||||
|
_CONFIG: Optional[Dict[str, Dict[str, Any]]] = None
|
||||||
|
|
||||||
|
|
||||||
|
def load_model_config() -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""models.yaml -> {model_name: {deployer, image, ...}} (cached; {} if absent)."""
|
||||||
|
global _CONFIG
|
||||||
|
if _CONFIG is None:
|
||||||
|
import yaml # optional; falls back to {} without it
|
||||||
|
|
||||||
|
path = _models_yaml_path()
|
||||||
|
_CONFIG = {}
|
||||||
|
if path.exists():
|
||||||
|
with open(path, encoding='utf-8') as f:
|
||||||
|
_CONFIG = (yaml.safe_load(f) or {}).get('models', {}) or {}
|
||||||
|
return _CONFIG
|
||||||
|
|
||||||
|
|
||||||
|
def _free_port() -> int:
|
||||||
|
import socket
|
||||||
|
|
||||||
|
with socket.socket() as s:
|
||||||
|
s.bind(('', 0))
|
||||||
|
return s.getsockname()[1]
|
||||||
|
|
||||||
|
|
||||||
|
class Deployer:
|
||||||
|
"""Base class: deploy(name, cfg) -> {'api_base', 'model'} (idempotent)."""
|
||||||
|
|
||||||
|
name = 'base'
|
||||||
|
|
||||||
|
def deploy(self, model: str, cfg: Dict[str, Any]) -> Dict[str, str]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def stop(self, handle: Dict[str, Any]) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@register_deployer('external')
|
||||||
|
class External(Deployer):
|
||||||
|
"""Endpoint already exists; cfg: api_base, model, api_key."""
|
||||||
|
|
||||||
|
name = 'external'
|
||||||
|
|
||||||
|
def deploy(self, model: str, cfg: Dict[str, Any]) -> Dict[str, str]:
|
||||||
|
api_base = cfg.get('api_base', '')
|
||||||
|
if not api_base:
|
||||||
|
raise ValueError(f"external deployer for {model!r} needs api_base in models.yaml")
|
||||||
|
return {'api_base': api_base, 'model': cfg.get('model', model)}
|
||||||
|
|
||||||
|
|
||||||
|
class DockerServeDeployer(Deployer):
|
||||||
|
"""Shared docker-run logic for OpenAI-protocol serving engines."""
|
||||||
|
|
||||||
|
engine_args: List[str] = []
|
||||||
|
|
||||||
|
def deploy(self, model: str, cfg: Dict[str, Any]) -> Dict[str, str]:
|
||||||
|
image = cfg.get('image', self.default_image)
|
||||||
|
port = int(cfg.get('port', 0) or _free_port())
|
||||||
|
hf = cfg.get('hf_home', os.environ.get('HF_HOME', '~/.cache/huggingface'))
|
||||||
|
gpus = cfg.get('gpus', 'all')
|
||||||
|
cmd = [
|
||||||
|
'docker', 'run', '-d', '--rm',
|
||||||
|
'--name', f'evalharness-{self.name}-{model}-{port}'.replace('/', '-'),
|
||||||
|
'--gpus', f'device={gpus}' if str(gpus).isdigit() else str(gpus),
|
||||||
|
'-p', f'{port}:8000',
|
||||||
|
'-v', f'{Path(hf).expanduser()}:/root/.cache/huggingface',
|
||||||
|
'-e', f'HF_ENDPOINT={os.environ.get("HF_ENDPOINT", "https://hf-mirror.com")}',
|
||||||
|
image, '--model', cfg.get('model_id', cfg.get('model', model)),
|
||||||
|
'--served-model-name', model,
|
||||||
|
*self.engine_args,
|
||||||
|
*shlex.split(cfg.get('extra_args', '')),
|
||||||
|
]
|
||||||
|
if cfg.get('gpu_mem_util'):
|
||||||
|
cmd += ['--gpu-memory-utilization', str(cfg['gpu_mem_util'])]
|
||||||
|
if cfg.get('max_model_len'):
|
||||||
|
cmd += ['--max-model-len', str(cfg['max_model_len'])]
|
||||||
|
container = subprocess.run(cmd, capture_output=True, text=True, check=True).stdout.strip()
|
||||||
|
api_base = f'http://localhost:{port}/v1'
|
||||||
|
self._wait_healthy(api_base, cfg.get('timeout_s', 1800))
|
||||||
|
return {'api_base': api_base, 'model': model, 'container': container}
|
||||||
|
|
||||||
|
def _wait_healthy(self, api_base: str, timeout_s: int) -> None:
|
||||||
|
deadline = time.time() + timeout_s
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
req = urllib.request.Request(f'{api_base}/models')
|
||||||
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||||
|
if resp.status == 200:
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
time.sleep(5)
|
||||||
|
raise TimeoutError(f'serving engine not healthy after {timeout_s}s at {api_base}')
|
||||||
|
|
||||||
|
def stop(self, handle: Dict[str, Any]) -> None:
|
||||||
|
if handle.get('container'):
|
||||||
|
subprocess.run(['docker', 'rm', '-f', handle['container']], check=False)
|
||||||
|
|
||||||
|
|
||||||
|
@register_deployer('vllm')
|
||||||
|
class VLLMDeployer(DockerServeDeployer):
|
||||||
|
name = 'vllm'
|
||||||
|
default_image = 'vllm/vllm-openai:v0.9.2'
|
||||||
|
|
||||||
|
|
||||||
|
@register_deployer('sglang')
|
||||||
|
class SGLangDeployer(DockerServeDeployer):
|
||||||
|
name = 'sglang'
|
||||||
|
default_image = 'lmsysorg/sglang:latest'
|
||||||
|
|
||||||
|
|
||||||
|
_ACTIVE: Dict[str, Dict[str, str]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def deploy(deployer: str, model: str) -> Dict[str, str]:
|
||||||
|
"""Resolve deploy:<deployer>/<model> specs. Idempotent per (deployer, model)."""
|
||||||
|
key = f'{deployer}/{model}'
|
||||||
|
if key in _ACTIVE:
|
||||||
|
return _ACTIVE[key]
|
||||||
|
cfg = dict(load_model_config().get(model, {}))
|
||||||
|
cls = DEPLOYER_REGISTRY.get(deployer)
|
||||||
|
handle = cls().deploy(model, cfg)
|
||||||
|
_ACTIVE[key] = handle
|
||||||
|
return handle
|
||||||
|
|
||||||
|
|
||||||
|
def stop_all() -> None:
|
||||||
|
"""Stop everything this process started (atexit-registered by runner)."""
|
||||||
|
for key, handle in _ACTIVE.items():
|
||||||
|
cls = DEPLOYER_REGISTRY.get(key.split('/', 1)[0])
|
||||||
|
try:
|
||||||
|
cls().stop(handle)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_ACTIVE.clear()
|
||||||
59
evalharness/model/output.py
Normal file
59
evalharness/model/output.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
"""Structured model output -- the hinge between single-turn eval and agents.
|
||||||
|
|
||||||
|
Every ModelAdapter returns ModelOutput, never a bare string:
|
||||||
|
- single-turn recipes read .text
|
||||||
|
- agent loops read .tool_calls and feed observations back
|
||||||
|
- accounting/monitoring reads .usage
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ToolCall(BaseModel):
|
||||||
|
"""One function call the model wants executed (OpenAI tool_calls shape)."""
|
||||||
|
|
||||||
|
id: str = ''
|
||||||
|
name: str
|
||||||
|
arguments: str = '' # JSON-encoded args string
|
||||||
|
arguments_dict: Dict[str, Any] = Field(default_factory=dict) # parsed convenience
|
||||||
|
|
||||||
|
def to_openai(self) -> Dict[str, Any]:
|
||||||
|
return {'id': self.id or f'call_{self.name}', 'type': 'function',
|
||||||
|
'function': {'name': self.name, 'arguments': self.arguments or '{}'}}
|
||||||
|
|
||||||
|
|
||||||
|
class Usage(BaseModel):
|
||||||
|
input_tokens: int = 0
|
||||||
|
output_tokens: int = 0
|
||||||
|
total_tokens: int = 0
|
||||||
|
cost: float = 0.0
|
||||||
|
latency_s: float = 0.0
|
||||||
|
finish_reason: str = ''
|
||||||
|
|
||||||
|
def __add__(self, other: 'Usage') -> 'Usage':
|
||||||
|
return Usage(
|
||||||
|
input_tokens=self.input_tokens + other.input_tokens,
|
||||||
|
output_tokens=self.output_tokens + other.output_tokens,
|
||||||
|
total_tokens=self.total_tokens + other.total_tokens,
|
||||||
|
cost=round(self.cost + other.cost, 6),
|
||||||
|
latency_s=round(self.latency_s + other.latency_s, 3),
|
||||||
|
finish_reason=self.finish_reason or other.finish_reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ModelOutput(BaseModel):
|
||||||
|
"""What every adapter returns. text may be '' when the model only calls tools."""
|
||||||
|
|
||||||
|
text: str = ''
|
||||||
|
tool_calls: List[ToolCall] = Field(default_factory=list)
|
||||||
|
usage: Usage = Field(default_factory=Usage)
|
||||||
|
raw: Optional[Dict[str, Any]] = None # provider response (audit/retry)
|
||||||
|
model: str = ''
|
||||||
|
created_at: str = Field(default_factory=lambda: time.strftime('%Y-%m-%d %H:%M:%S'))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_tool_call(self) -> bool:
|
||||||
|
return bool(self.tool_calls)
|
||||||
168
evalharness/model/runner.py
Normal file
168
evalharness/model/runner.py
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
"""Async generation runner: model + dataset -> predictions -> scored report.
|
||||||
|
|
||||||
|
The async boundary is exactly "waiting on the model". Data loading and
|
||||||
|
scoring stay synchronous (fast, CPU/disk bound); this coroutine fans out
|
||||||
|
model calls with a semaphore, streams progress, then hands the collected
|
||||||
|
raw strings to the sync evaluate().
|
||||||
|
|
||||||
|
from evalharness.model import run_eval
|
||||||
|
report = asyncio.run(run_eval(ds, 'mock', limit=50)) # offline smoke
|
||||||
|
report = asyncio.run(run_eval(ds, 'openai/http://gpu03:8000/v1?qwen3-8b'))
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, List, Optional, Union
|
||||||
|
|
||||||
|
from ..data.dataset import Dataset
|
||||||
|
from ..data.sample import ChatMessage, Sample
|
||||||
|
from ..eval.recipe import EvalRecipe
|
||||||
|
from ..eval.record import EvalReport
|
||||||
|
from ..eval.runner import evaluate
|
||||||
|
from .adapter import ModelAdapter, resolve_adapter
|
||||||
|
from .output import Usage
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_predictions(
|
||||||
|
adapter: ModelAdapter,
|
||||||
|
samples: List[Sample],
|
||||||
|
concurrency: int = 32,
|
||||||
|
limit: Optional[int] = None,
|
||||||
|
gen_kwargs: Optional[Dict[str, Any]] = None,
|
||||||
|
progress: bool = True,
|
||||||
|
) -> tuple:
|
||||||
|
"""Fan out model calls; returns (raws, total_usage).
|
||||||
|
|
||||||
|
Each sample becomes one user message (or its ChatMessage list is used
|
||||||
|
verbatim for multi-turn samples). Tool declarations from sample.tools
|
||||||
|
are passed through so fc/agent recipes degrade gracefully today and
|
||||||
|
agent loops can reuse this adapter untouched.
|
||||||
|
"""
|
||||||
|
gen_kwargs = gen_kwargs or {}
|
||||||
|
sem = asyncio.Semaphore(concurrency)
|
||||||
|
total_usage = Usage()
|
||||||
|
done_count = 0
|
||||||
|
t0 = time.time()
|
||||||
|
raws: List[str] = []
|
||||||
|
usages: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
async def one(sample: Sample) -> tuple:
|
||||||
|
nonlocal done_count, total_usage
|
||||||
|
messages = ([ChatMessage(role='user', content=sample.input)] if isinstance(sample.input, str)
|
||||||
|
else list(sample.input))
|
||||||
|
tools = None
|
||||||
|
if sample.tools:
|
||||||
|
tools = [{'name': t.name, 'description': t.description or '',
|
||||||
|
'parameters': t.parameters} for t in sample.tools]
|
||||||
|
if getattr(adapter, 'name', '') == 'mock' and adapter.extra.get('mode') == 'boxed' \
|
||||||
|
and sample.target not in ('', None):
|
||||||
|
# oracle channel for mock:boxed so full pipelines verify offline
|
||||||
|
messages = messages + [ChatMessage(role='user',
|
||||||
|
content=f'MOCKTARGET::{sample.target}')]
|
||||||
|
async with sem:
|
||||||
|
out = await adapter.generate(messages, tools=tools, **gen_kwargs)
|
||||||
|
total_usage = total_usage + out.usage
|
||||||
|
text = out.text
|
||||||
|
if out.tool_calls: # fc tasks: serialize calls as the prediction
|
||||||
|
import json
|
||||||
|
|
||||||
|
text = (text + '\n' if text else '') + json.dumps(
|
||||||
|
[c.to_openai()['function'] for c in out.tool_calls], ensure_ascii=False)
|
||||||
|
usage = out.usage.model_dump()
|
||||||
|
done_count += 1
|
||||||
|
if progress and (done_count % 20 == 0 or done_count == len(samples)):
|
||||||
|
rate = done_count / max(time.time() - t0, 1e-6)
|
||||||
|
print(f' [{done_count}/{len(samples)}] {rate:.1f} samples/s '
|
||||||
|
f'tokens={total_usage.total_tokens}', flush=True)
|
||||||
|
return text, usage
|
||||||
|
|
||||||
|
work = samples[:limit] if limit else samples
|
||||||
|
pairs = await asyncio.gather(*(one(s) for s in work))
|
||||||
|
raws = [p[0] for p in pairs]
|
||||||
|
usages = [p[1] for p in pairs]
|
||||||
|
return raws, usages, total_usage
|
||||||
|
|
||||||
|
|
||||||
|
async def run_eval(
|
||||||
|
dataset: Union[Dataset, List[Sample]],
|
||||||
|
model_spec: str,
|
||||||
|
recipe: Optional[EvalRecipe] = None,
|
||||||
|
*,
|
||||||
|
concurrency: int = 32,
|
||||||
|
limit: Optional[int] = None,
|
||||||
|
gen_kwargs: Optional[Dict[str, Any]] = None,
|
||||||
|
judge_spec: Optional[str] = None,
|
||||||
|
judge: Optional[Any] = None,
|
||||||
|
progress: bool = True,
|
||||||
|
) -> EvalReport:
|
||||||
|
"""Generate + score in one call. Model spec examples:
|
||||||
|
'mock', 'mock:boxed', 'openai/http://gpu03:8000/v1?qwen3-8b', 'deploy:vllm/qwen3-8b'.
|
||||||
|
"""
|
||||||
|
adapter = _make_adapter(model_spec)
|
||||||
|
spec = getattr(dataset, 'spec', None)
|
||||||
|
name = spec.name if spec is not None else 'adhoc'
|
||||||
|
if recipe is None:
|
||||||
|
from ..eval.recipe import EvalRecipe, get_eval
|
||||||
|
|
||||||
|
try:
|
||||||
|
recipe = get_eval(name)
|
||||||
|
except KeyError:
|
||||||
|
if name != 'adhoc':
|
||||||
|
raise
|
||||||
|
recipe = EvalRecipe(name='adhoc', extract='identity',
|
||||||
|
scorers={'acc': {'name': 'exact', 'mode': 'raw'}})
|
||||||
|
samples = list(dataset)[:limit] if limit else list(dataset)
|
||||||
|
if progress:
|
||||||
|
print(f'generating: {adapter} on {len(samples)} samples '
|
||||||
|
f'(concurrency={concurrency})', flush=True)
|
||||||
|
try:
|
||||||
|
raws, usages, usage = await generate_predictions(adapter, samples, concurrency,
|
||||||
|
progress=progress, gen_kwargs=gen_kwargs)
|
||||||
|
finally:
|
||||||
|
await adapter.close()
|
||||||
|
if judge is None and judge_spec:
|
||||||
|
judge_adapter = _make_adapter(judge_spec)
|
||||||
|
judge = _judge_callable(judge_adapter)
|
||||||
|
|
||||||
|
preds = [{'raw': r, 'usage': u} for r, u in zip(raws, usages)]
|
||||||
|
report = evaluate(
|
||||||
|
samples, preds, recipe,
|
||||||
|
model=model_spec,
|
||||||
|
judge=judge,
|
||||||
|
extra_metadata={'gen_input_tokens': usage.input_tokens,
|
||||||
|
'gen_output_tokens': usage.output_tokens,
|
||||||
|
'gen_total_tokens': usage.total_tokens},
|
||||||
|
)
|
||||||
|
report.model = model_spec
|
||||||
|
report.dataset = name
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def _make_adapter(spec: str) -> ModelAdapter:
|
||||||
|
"""'mock:boxed' -> MockAdapter(mode='boxed'); else resolve_adapter().
|
||||||
|
|
||||||
|
The colon-mode syntax exists ONLY for 'mock': adapter names contain no
|
||||||
|
scheme/colon, so 'mock:xxx' is safe while URLs ('openai/http://...')
|
||||||
|
must never be split on ':'.
|
||||||
|
"""
|
||||||
|
base, sep, mode = spec.partition(':')
|
||||||
|
if sep and '/' not in base and base == 'mock':
|
||||||
|
adapter = resolve_adapter('mock')
|
||||||
|
adapter.extra['mode'] = mode or 'echo'
|
||||||
|
return adapter
|
||||||
|
return resolve_adapter(spec)
|
||||||
|
|
||||||
|
|
||||||
|
def _judge_callable(judge_adapter: ModelAdapter):
|
||||||
|
async def ask(messages) -> str:
|
||||||
|
out = await judge_adapter.generate([ChatMessage(role='user', content=str(m)) for m in messages]
|
||||||
|
if isinstance(messages, list) and messages and isinstance(messages[0], dict)
|
||||||
|
else messages)
|
||||||
|
return out.text
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
def sync_ask(messages):
|
||||||
|
return asyncio.run(ask(messages))
|
||||||
|
|
||||||
|
return sync_ask
|
||||||
118
tests/test_model.py
Normal file
118
tests/test_model.py
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
"""Model layer tests: spec parsing, adapters, async runner end-to-end.
|
||||||
|
|
||||||
|
Run: .venv/bin/python tests/test_model.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from evalharness.data.sample import Sample, ToolInfo # noqa: E402
|
||||||
|
from evalharness.model import MockAdapter, OpenAICompatible, resolve_adapter, run_eval # noqa: E402
|
||||||
|
from evalharness.model.adapter import parse_model_spec # noqa: E402
|
||||||
|
from evalharness.model.runner import _make_adapter # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_spec():
|
||||||
|
got = parse_model_spec('openai/http://gpu03:8000/v1?qwen3-8b')
|
||||||
|
assert got == {'adapter': 'openai', 'api_base': 'http://gpu03:8000/v1', 'model': 'qwen3-8b'}
|
||||||
|
assert parse_model_spec('mock') == {'adapter': 'mock', 'api_base': '', 'model': ''}
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_adapter_mock_modes():
|
||||||
|
a = _make_adapter('mock:boxed')
|
||||||
|
assert isinstance(a, MockAdapter) and a.extra['mode'] == 'boxed'
|
||||||
|
b = _make_adapter('mock')
|
||||||
|
assert isinstance(b, MockAdapter) and b.extra.get('mode', 'echo') == 'echo'
|
||||||
|
c = _make_adapter('openai/http://127.0.0.1:9/v1?m')
|
||||||
|
assert isinstance(c, OpenAICompatible) and c.api_base == 'http://127.0.0.1:9/v1' and c.model == 'm'
|
||||||
|
|
||||||
|
|
||||||
|
def test_mock_tool_mode():
|
||||||
|
out = asyncio.run(MockAdapter().generate([Sample(input='x').input
|
||||||
|
and __import__('evalharness.data.sample', fromlist=['ChatMessage']).ChatMessage(role='user', content='x')]))
|
||||||
|
assert out.text == 'x'
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_server():
|
||||||
|
class Fake(BaseHTTPRequestHandler):
|
||||||
|
def do_POST(self):
|
||||||
|
body = json.loads(self.rfile.read(int(self.headers['Content-Length'])))
|
||||||
|
msg = body['messages'][-1]['content']
|
||||||
|
tool_calls = []
|
||||||
|
if body.get('tools'):
|
||||||
|
tool_calls = [{'id': 'c1', 'function': {'name': 't1', 'arguments': '{"x": 1}'}}]
|
||||||
|
resp = {'choices': [{'message': {'content': f'echo:{msg}', 'tool_calls': tool_calls},
|
||||||
|
'finish_reason': 'tool_calls' if tool_calls else 'stop'}],
|
||||||
|
'usage': {'prompt_tokens': 3, 'completion_tokens': 5, 'total_tokens': 8},
|
||||||
|
'model': body['model']}
|
||||||
|
data = json.dumps(resp).encode()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header('Content-Type', 'application/json')
|
||||||
|
self.send_header('Content-Length', str(len(data)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
srv = HTTPServer(('127.0.0.1', 0), Fake)
|
||||||
|
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||||
|
return srv
|
||||||
|
|
||||||
|
|
||||||
|
def test_openai_adapter_roundtrip():
|
||||||
|
srv = _fake_server()
|
||||||
|
port = srv.server_address[1]
|
||||||
|
try:
|
||||||
|
rep = asyncio.run(run_eval([Sample(input='hello', target='echo:hello')],
|
||||||
|
f'openai/http://127.0.0.1:{port}/v1?fake-m',
|
||||||
|
progress=False))
|
||||||
|
r = rep.samples[0]
|
||||||
|
assert 'echo:hello' in r.raw_prediction
|
||||||
|
assert r.usage['total_tokens'] == 8
|
||||||
|
finally:
|
||||||
|
srv.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
def test_tools_pass_through():
|
||||||
|
srv = _fake_server()
|
||||||
|
port = srv.server_address[1]
|
||||||
|
try:
|
||||||
|
s = Sample(input='call it', tools=[ToolInfo(name='f1', parameters={'type': 'object'})])
|
||||||
|
rep = asyncio.run(run_eval([s], f'openai/http://127.0.0.1:{port}/v1?fake-m',
|
||||||
|
progress=False))
|
||||||
|
assert '"t1"' in rep.samples[0].raw_prediction # tool call serialized into prediction
|
||||||
|
finally:
|
||||||
|
srv.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
def test_oracle_mock_pipeline():
|
||||||
|
from evalharness import get_dataset
|
||||||
|
|
||||||
|
ds = get_dataset('gsm8k')
|
||||||
|
rep = asyncio.run(run_eval(ds, 'mock:boxed', limit=50, concurrency=8, progress=False))
|
||||||
|
assert rep.metrics['acc'] == 1.0
|
||||||
|
assert rep.dataset == 'gsm8k'
|
||||||
|
assert rep.metric_groups['run_info']['gen_total_tokens'] == 100
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
fails = 0
|
||||||
|
for name, fn in sorted({k: v for k, v in globals().items()
|
||||||
|
if k.startswith('test_') and callable(v)}.items()):
|
||||||
|
try:
|
||||||
|
fn()
|
||||||
|
print(f'PASS {name}')
|
||||||
|
except AssertionError as e:
|
||||||
|
fails += 1
|
||||||
|
print(f'FAIL {name}: {e}')
|
||||||
|
except Exception as e:
|
||||||
|
fails += 1
|
||||||
|
print(f'ERROR {name}: {type(e).__name__}: {e}')
|
||||||
|
sys.exit(1 if fails else 0)
|
||||||
Loading…
x
Reference in New Issue
Block a user