vlmeter 0.2.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.
- vlmeter/__init__.py +3 -0
- vlmeter/__main__.py +3 -0
- vlmeter/cli.py +135 -0
- vlmeter/dataset.py +188 -0
- vlmeter/images.py +56 -0
- vlmeter/judge.py +75 -0
- vlmeter/models/__init__.py +61 -0
- vlmeter/models/anthropic_client.py +51 -0
- vlmeter/models/base.py +72 -0
- vlmeter/models/gemini_client.py +50 -0
- vlmeter/models/mock.py +40 -0
- vlmeter/models/ollama_client.py +42 -0
- vlmeter/models/openai_client.py +48 -0
- vlmeter/prompting.py +30 -0
- vlmeter/report.py +210 -0
- vlmeter/retry.py +31 -0
- vlmeter/runner.py +305 -0
- vlmeter/scoring.py +195 -0
- vlmeter-0.2.0.dist-info/LICENSE +21 -0
- vlmeter-0.2.0.dist-info/METADATA +206 -0
- vlmeter-0.2.0.dist-info/RECORD +24 -0
- vlmeter-0.2.0.dist-info/WHEEL +5 -0
- vlmeter-0.2.0.dist-info/entry_points.txt +2 -0
- vlmeter-0.2.0.dist-info/top_level.txt +1 -0
vlmeter/__init__.py
ADDED
vlmeter/__main__.py
ADDED
vlmeter/cli.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Command-line interface: run / report / list-models."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .models import load_registry
|
|
12
|
+
from .runner import RESULTS_DIR, RunOptions, run_eval
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _load_dotenv() -> None:
|
|
16
|
+
env = Path(".env")
|
|
17
|
+
if not env.exists():
|
|
18
|
+
return
|
|
19
|
+
for line in env.read_text(encoding="utf-8").splitlines():
|
|
20
|
+
line = line.strip()
|
|
21
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
22
|
+
continue
|
|
23
|
+
k, _, v = line.partition("=")
|
|
24
|
+
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _common_args(p: argparse.ArgumentParser) -> None:
|
|
28
|
+
p.add_argument("--registry", type=Path, default=None, help="models.yaml 路径(默认 config/models.yaml)")
|
|
29
|
+
p.add_argument("--results-dir", type=Path, default=RESULTS_DIR)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
33
|
+
parser = argparse.ArgumentParser(
|
|
34
|
+
prog="vlmeter", description="多模态大模型准确率评测流水线"
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument("--version", action="version", version=f"vlmeter {__version__}")
|
|
37
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
38
|
+
|
|
39
|
+
run = sub.add_parser("run", help="跑评测(支持断点续跑)")
|
|
40
|
+
run.add_argument("--models", required=True, help="逗号分隔的模型名,见 list-models")
|
|
41
|
+
run.add_argument("--data", required=True, type=Path, help="数据集路径(.jsonl / .csv)")
|
|
42
|
+
run.add_argument("--judge", default=None, help="judge 裁判模型名(VQA 语义判分)")
|
|
43
|
+
run.add_argument("--limit", type=int, default=None, help="只评测前 N 题")
|
|
44
|
+
run.add_argument("--fresh", action="store_true", help="忽略已缓存结果,全部重跑")
|
|
45
|
+
_common_args(run)
|
|
46
|
+
|
|
47
|
+
rep = sub.add_parser("report", help="由已有结果生成对比报告")
|
|
48
|
+
rep.add_argument("--data", required=True, type=Path)
|
|
49
|
+
rep.add_argument("--models", default=None, help="逗号分隔;缺省为有结果文件的所有注册模型")
|
|
50
|
+
_common_args(rep)
|
|
51
|
+
|
|
52
|
+
sub.add_parser("list-models", help="列出模型注册表").add_argument(
|
|
53
|
+
"--registry", type=Path, default=None
|
|
54
|
+
)
|
|
55
|
+
return parser
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _resolve_model_names(args) -> list[str]:
|
|
59
|
+
registry = load_registry(args.registry)
|
|
60
|
+
if getattr(args, "models", None):
|
|
61
|
+
names = [m.strip() for m in args.models.split(",") if m.strip()]
|
|
62
|
+
unknown = [m for m in names if m not in registry]
|
|
63
|
+
if unknown:
|
|
64
|
+
sys.exit(f"未知模型: {unknown},可选: {sorted(registry)}")
|
|
65
|
+
return names
|
|
66
|
+
# report without --models: any registered model that has a result file
|
|
67
|
+
from .dataset import load_dataset
|
|
68
|
+
from .runner import result_path
|
|
69
|
+
|
|
70
|
+
ds = load_dataset(args.data)
|
|
71
|
+
names = [
|
|
72
|
+
n for n in registry
|
|
73
|
+
if result_path(n, ds, args.results_dir).exists()
|
|
74
|
+
]
|
|
75
|
+
if not names:
|
|
76
|
+
sys.exit("没有找到任何结果文件;请先运行 vlmeter run")
|
|
77
|
+
return names
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def main(argv: list[str] | None = None) -> None:
|
|
81
|
+
_load_dotenv()
|
|
82
|
+
args = build_parser().parse_args(argv)
|
|
83
|
+
|
|
84
|
+
if args.command == "list-models":
|
|
85
|
+
registry = load_registry(args.registry)
|
|
86
|
+
print(f"{'名称':<28}{'provider':<12}{'模型ID':<30}{'单价(入/出 ¥/M)':<18}密钥环境变量")
|
|
87
|
+
print("-" * 110)
|
|
88
|
+
for name, cfg in registry.items():
|
|
89
|
+
key = cfg.api_key_env or "-"
|
|
90
|
+
price = (
|
|
91
|
+
f"{cfg.price_input}/{cfg.price_output}" if cfg.price_input is not None else "-"
|
|
92
|
+
)
|
|
93
|
+
missing = (
|
|
94
|
+
f"(未设置 {key}!)" if cfg.api_key_env and not os.environ.get(cfg.api_key_env)
|
|
95
|
+
else ""
|
|
96
|
+
)
|
|
97
|
+
print(f"{name:<28}{cfg.provider:<12}{cfg.model:<30}{price:<18}{key}{missing}")
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
if args.command == "run":
|
|
101
|
+
opts = RunOptions(
|
|
102
|
+
models=[m.strip() for m in args.models.split(",") if m.strip()],
|
|
103
|
+
data=args.data,
|
|
104
|
+
judge=args.judge,
|
|
105
|
+
limit=args.limit,
|
|
106
|
+
fresh=args.fresh,
|
|
107
|
+
registry_path=args.registry,
|
|
108
|
+
results_dir=args.results_dir,
|
|
109
|
+
)
|
|
110
|
+
summaries = asyncio.run(run_eval(opts))
|
|
111
|
+
print("\n运行完成:")
|
|
112
|
+
for s in summaries:
|
|
113
|
+
print(
|
|
114
|
+
f" {s.model}: 新作答 {s.answered},跳过已缓存 {s.skipped_no_change},"
|
|
115
|
+
f"失败 {s.errors},未判定 {s.unscored}"
|
|
116
|
+
)
|
|
117
|
+
# fall through to report generation below with resolved models
|
|
118
|
+
args.models = ",".join(opts.models)
|
|
119
|
+
|
|
120
|
+
from .dataset import load_dataset
|
|
121
|
+
from .report import console_summary, generate_report, report_path
|
|
122
|
+
|
|
123
|
+
registry = load_registry(args.registry)
|
|
124
|
+
dataset = load_dataset(args.data)
|
|
125
|
+
names = _resolve_model_names(args)
|
|
126
|
+
args.results_dir.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
console_summary(dataset, names, args.results_dir, registry)
|
|
128
|
+
md = generate_report(dataset, names, args.results_dir, registry=registry)
|
|
129
|
+
out = report_path(dataset, args.results_dir)
|
|
130
|
+
out.write_text(md, encoding="utf-8")
|
|
131
|
+
print(f"\n报告已生成: {out}")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
if __name__ == "__main__":
|
|
135
|
+
main()
|
vlmeter/dataset.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Dataset loading and validation.
|
|
2
|
+
|
|
3
|
+
A dataset is a JSONL file (one item per line) or a CSV with the same columns:
|
|
4
|
+
|
|
5
|
+
id unique id (auto-generated from row index if missing)
|
|
6
|
+
image path to the image, relative to the dataset file
|
|
7
|
+
task "classification" | "vqa" | "checklist" (default: vqa)
|
|
8
|
+
question prompt shown to the model (not needed for checklist)
|
|
9
|
+
choices classification: option list, e.g. ["猫", "狗", "鸟"]
|
|
10
|
+
answer ground truth: option text or its letter (classification), or the
|
|
11
|
+
reference answer text (vqa)
|
|
12
|
+
category optional, used for per-category breakdown in the report
|
|
13
|
+
match optional vqa scoring strategy: exact | contains | numeric | judge
|
|
14
|
+
tolerance optional numeric tolerance for match=numeric
|
|
15
|
+
checks checklist only: [{"name": "有自行车", "answer": true,
|
|
16
|
+
"criteria": "可见完整的自行车或其主要部分,含部分遮挡"}] —
|
|
17
|
+
one image, multiple yes/no scene checks, answered in ONE call;
|
|
18
|
+
name 短名用于报告,criteria 判定标准只进提示词(可选)
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import hashlib
|
|
23
|
+
import json
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
VALID_TASKS = {"classification", "vqa", "checklist"}
|
|
28
|
+
VALID_MATCH = {"exact", "contains", "numeric", "judge"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Check:
|
|
33
|
+
name: str # 短名:用于报告列名与结果 ID
|
|
34
|
+
answer: bool
|
|
35
|
+
criteria: str = "" # 判定标准:写进提示词,对齐模型与标注员的主观标准
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Item:
|
|
40
|
+
id: str
|
|
41
|
+
image: str
|
|
42
|
+
task: str = "vqa"
|
|
43
|
+
question: str = ""
|
|
44
|
+
choices: list[str] = field(default_factory=list)
|
|
45
|
+
answer: str = ""
|
|
46
|
+
category: str = "未分类"
|
|
47
|
+
match: str | None = None
|
|
48
|
+
tolerance: float = 0.0
|
|
49
|
+
checks: list[Check] = field(default_factory=list)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def check_record_id(item_id: str, check_name: str) -> str:
|
|
53
|
+
return f"{item_id}::{check_name}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def item_record_ids(item: Item) -> list[str]:
|
|
57
|
+
"""Result-record ids an item expands to (checklist items yield one per check)."""
|
|
58
|
+
if item.task == "checklist":
|
|
59
|
+
return [check_record_id(item.id, c.name) for c in item.checks]
|
|
60
|
+
return [item.id]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class Dataset:
|
|
65
|
+
items: list[Item]
|
|
66
|
+
path: Path
|
|
67
|
+
fingerprint: str # md5 of the raw file, used to key result caching
|
|
68
|
+
|
|
69
|
+
def __len__(self) -> int:
|
|
70
|
+
return len(self.items)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def total_records(self) -> int:
|
|
74
|
+
return sum(len(item_record_ids(it)) for it in self.items)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def load_dataset(path: str | Path) -> Dataset:
|
|
78
|
+
p = Path(path).resolve()
|
|
79
|
+
if not p.exists():
|
|
80
|
+
raise FileNotFoundError(f"dataset not found: {p}")
|
|
81
|
+
fingerprint = hashlib.md5(p.read_bytes()).hexdigest()
|
|
82
|
+
|
|
83
|
+
rows: list[dict] = []
|
|
84
|
+
if p.suffix.lower() == ".csv":
|
|
85
|
+
import pandas as pd
|
|
86
|
+
|
|
87
|
+
df = pd.read_csv(p, dtype=str, keep_default_na=False)
|
|
88
|
+
for _, row in df.iterrows():
|
|
89
|
+
rows.append(dict(row))
|
|
90
|
+
else:
|
|
91
|
+
for i, line in enumerate(p.read_text(encoding="utf-8").splitlines(), 1):
|
|
92
|
+
line = line.strip()
|
|
93
|
+
if not line or line.startswith("#"):
|
|
94
|
+
continue
|
|
95
|
+
try:
|
|
96
|
+
rows.append(json.loads(line))
|
|
97
|
+
except json.JSONDecodeError as e:
|
|
98
|
+
raise ValueError(f"{p.name} line {i}: invalid JSON ({e})") from e
|
|
99
|
+
|
|
100
|
+
items: list[Item] = []
|
|
101
|
+
errors: list[str] = []
|
|
102
|
+
for i, row in enumerate(rows):
|
|
103
|
+
item, err = _build_item(row, i, p.parent)
|
|
104
|
+
if err:
|
|
105
|
+
errors.append(err)
|
|
106
|
+
else:
|
|
107
|
+
items.append(item)
|
|
108
|
+
if errors:
|
|
109
|
+
raise ValueError("dataset validation failed:\n " + "\n ".join(errors))
|
|
110
|
+
if not items:
|
|
111
|
+
raise ValueError(f"dataset {p.name} has no items")
|
|
112
|
+
return Dataset(items=items, path=p, fingerprint=fingerprint)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _build_item(row: dict, index: int, base_dir: Path) -> tuple[Item | None, str | None]:
|
|
116
|
+
where = f"row {index}" + (f" (id={row.get('id')})" if row.get("id") else "")
|
|
117
|
+
|
|
118
|
+
image = str(row.get("image") or "").strip()
|
|
119
|
+
if not image:
|
|
120
|
+
return None, f"{where}: missing 'image'"
|
|
121
|
+
image_path = Path(image)
|
|
122
|
+
if not image_path.is_absolute():
|
|
123
|
+
image_path = (base_dir / image_path).resolve()
|
|
124
|
+
if not image_path.exists():
|
|
125
|
+
return None, f"{where}: image not found: {image_path}"
|
|
126
|
+
|
|
127
|
+
task = str(row.get("task") or "vqa").strip().lower()
|
|
128
|
+
if task not in VALID_TASKS:
|
|
129
|
+
return None, f"{where}: unknown task '{task}' (expected {sorted(VALID_TASKS)})"
|
|
130
|
+
|
|
131
|
+
question = str(row.get("question") or "").strip()
|
|
132
|
+
if not question and task != "checklist":
|
|
133
|
+
return None, f"{where}: missing 'question'"
|
|
134
|
+
|
|
135
|
+
checks: list[Check] = []
|
|
136
|
+
if task == "checklist":
|
|
137
|
+
raw_checks = row.get("checks")
|
|
138
|
+
if not isinstance(raw_checks, list) or not raw_checks:
|
|
139
|
+
return None, f"{where}: checklist needs a non-empty 'checks' list"
|
|
140
|
+
for j, c in enumerate(raw_checks):
|
|
141
|
+
if not isinstance(c, dict) or not str(c.get("name") or "").strip():
|
|
142
|
+
return None, f"{where}: checks[{j}] needs a 'name'"
|
|
143
|
+
ans = c.get("answer")
|
|
144
|
+
if not isinstance(ans, bool):
|
|
145
|
+
return None, f"{where}: checks[{j}].answer must be true/false"
|
|
146
|
+
checks.append(
|
|
147
|
+
Check(
|
|
148
|
+
name=str(c["name"]).strip(),
|
|
149
|
+
answer=ans,
|
|
150
|
+
criteria=str(c.get("criteria") or "").strip(),
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
else:
|
|
154
|
+
answer = str(row.get("answer") if row.get("answer") is not None else "").strip()
|
|
155
|
+
if not answer:
|
|
156
|
+
return None, f"{where}: missing 'answer'"
|
|
157
|
+
|
|
158
|
+
choices_raw = row.get("choices")
|
|
159
|
+
choices = [str(c).strip() for c in choices_raw if str(c).strip()] if choices_raw else []
|
|
160
|
+
if task == "classification":
|
|
161
|
+
if len(choices) < 2:
|
|
162
|
+
return None, f"{where}: classification needs >=2 choices"
|
|
163
|
+
gold = answer if answer in choices else (
|
|
164
|
+
choices[ord(answer.upper()[0]) - 65] if len(answer) == 1 and "A" <= answer.upper()[0] <= "Z"
|
|
165
|
+
and ord(answer.upper()[0]) - 65 < len(choices) else None
|
|
166
|
+
)
|
|
167
|
+
if gold is None:
|
|
168
|
+
return None, f"{where}: answer '{answer}' not in choices {choices}"
|
|
169
|
+
|
|
170
|
+
match = row.get("match")
|
|
171
|
+
if match is not None:
|
|
172
|
+
match = str(match).strip().lower()
|
|
173
|
+
if match not in VALID_MATCH:
|
|
174
|
+
return None, f"{where}: unknown match '{match}' (expected {sorted(VALID_MATCH)})"
|
|
175
|
+
|
|
176
|
+
item = Item(
|
|
177
|
+
id=str(row.get("id") or f"q{index:04d}"),
|
|
178
|
+
image=str(image_path),
|
|
179
|
+
task=task,
|
|
180
|
+
question=question,
|
|
181
|
+
choices=choices,
|
|
182
|
+
answer=answer if task != "checklist" else "",
|
|
183
|
+
category=str(row.get("category") or "未分类"),
|
|
184
|
+
match=match,
|
|
185
|
+
tolerance=float(row.get("tolerance") or 0.0),
|
|
186
|
+
checks=checks,
|
|
187
|
+
)
|
|
188
|
+
return item, None
|
vlmeter/images.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Image loading, optional downscaling, and base64 encoding for each provider."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import io
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
MIME_BY_EXT = {
|
|
9
|
+
".png": "image/png",
|
|
10
|
+
".jpg": "image/jpeg",
|
|
11
|
+
".jpeg": "image/jpeg",
|
|
12
|
+
".webp": "image/webp",
|
|
13
|
+
".gif": "image/gif",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
# Providers differ; keep requests lean by capping the longest side.
|
|
17
|
+
MAX_SIDE = 1568
|
|
18
|
+
MAX_BYTES = 4 * 1024 * 1024
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _load_bytes(path: str | Path) -> tuple[bytes, str]:
|
|
22
|
+
p = Path(path)
|
|
23
|
+
if not p.exists():
|
|
24
|
+
raise FileNotFoundError(f"image not found: {p}")
|
|
25
|
+
mime = MIME_BY_EXT.get(p.suffix.lower(), "image/png")
|
|
26
|
+
return p.read_bytes(), mime
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def prepare_image(path: str | Path) -> tuple[bytes, str]:
|
|
30
|
+
"""Return (raw_bytes, mime). Downscale/re-encode oversized images."""
|
|
31
|
+
raw, mime = _load_bytes(path)
|
|
32
|
+
if len(raw) <= MAX_BYTES:
|
|
33
|
+
return raw, mime
|
|
34
|
+
|
|
35
|
+
from PIL import Image # imported lazily so non-image runs need no Pillow
|
|
36
|
+
|
|
37
|
+
img = Image.open(io.BytesIO(raw))
|
|
38
|
+
if img.mode not in ("RGB", "L"):
|
|
39
|
+
img = img.convert("RGB")
|
|
40
|
+
if max(img.size) > MAX_SIDE:
|
|
41
|
+
img.thumbnail((MAX_SIDE, MAX_SIDE))
|
|
42
|
+
buf = io.BytesIO()
|
|
43
|
+
img.save(buf, format="JPEG", quality=90)
|
|
44
|
+
return buf.getvalue(), "image/jpeg"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def b64_image(path: str | Path) -> tuple[str, str]:
|
|
48
|
+
"""Return (base64_no_prefix, mime)."""
|
|
49
|
+
raw, mime = prepare_image(path)
|
|
50
|
+
return base64.b64encode(raw).decode("ascii"), mime
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def data_url(path: str | Path) -> str:
|
|
54
|
+
"""Return a data: URL, accepted by OpenAI-compatible chat endpoints."""
|
|
55
|
+
b64, mime = b64_image(path)
|
|
56
|
+
return f"data:{mime};base64,{b64}"
|
vlmeter/judge.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""LLM-as-judge for open-ended VQA answers (text-only model call)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from .models.base import BaseVLModel
|
|
8
|
+
from .retry import retry_call
|
|
9
|
+
|
|
10
|
+
_JUDGE_PROMPT = """你是评测裁判。根据问题、标准答案和模型回答,判断模型回答是否正确。
|
|
11
|
+
判定标准:数值必须相等(单位不同但数值等价算对,如 1.5kg 与 1500g);文字答案允许表述差异,但含义必须与标准答案一致;回答中包含正确答案且无矛盾信息即算对。
|
|
12
|
+
|
|
13
|
+
问题:{question}
|
|
14
|
+
标准答案:{gold}
|
|
15
|
+
模型回答:{prediction}
|
|
16
|
+
|
|
17
|
+
只输出一个 JSON 对象,格式:{{"correct": true}} 或 {{"correct": false}},不要输出其他内容。"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_CHOOSE_PROMPT = """你是评测裁判。模型本应从选项中选一个并回答字母,但它输出了一段描述。
|
|
21
|
+
请判断模型的回答最符合哪个选项。
|
|
22
|
+
|
|
23
|
+
问题:{question}
|
|
24
|
+
选项:
|
|
25
|
+
{options}
|
|
26
|
+
模型回答:{prediction}
|
|
27
|
+
|
|
28
|
+
只输出一个 JSON 对象,格式:{{"choice": "A"}}(字母为最符合的选项),不要输出其他内容。如果模型回答与任何选项都不符,输出 {{"choice": null}}。"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Judge:
|
|
32
|
+
def __init__(self, model: BaseVLModel, attempts: int = 3):
|
|
33
|
+
self.model = model
|
|
34
|
+
self.attempts = attempts
|
|
35
|
+
|
|
36
|
+
async def _ask(self, prompt: str) -> str | None:
|
|
37
|
+
async def _call():
|
|
38
|
+
return await self.model.chat(None, prompt)
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
resp = await retry_call(_call, attempts=self.attempts)
|
|
42
|
+
except Exception as e: # noqa: BLE001
|
|
43
|
+
return None
|
|
44
|
+
m = re.search(r"\{[^{}]*\}", resp.text, re.S)
|
|
45
|
+
if not m:
|
|
46
|
+
return None
|
|
47
|
+
try:
|
|
48
|
+
return json.loads(m.group(0))
|
|
49
|
+
except json.JSONDecodeError:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
async def verify(self, question: str, gold: str, prediction: str) -> tuple[bool | None, str]:
|
|
53
|
+
"""Return (correct, reason). correct=None means judging failed."""
|
|
54
|
+
prompt = _JUDGE_PROMPT.format(question=question, gold=gold, prediction=prediction[:500])
|
|
55
|
+
verdict = await self._ask(prompt)
|
|
56
|
+
if isinstance(verdict, dict) and isinstance(verdict.get("correct"), bool):
|
|
57
|
+
return verdict["correct"], ""
|
|
58
|
+
return None, "judge_parse_fail"
|
|
59
|
+
|
|
60
|
+
async def choose(self, question: str, choices: list[str], prediction: str) -> int | None:
|
|
61
|
+
"""Map a free-form classification answer back to a choice index."""
|
|
62
|
+
from .prompting import LETTERS
|
|
63
|
+
|
|
64
|
+
options = "\n".join(f"{LETTERS[i]}. {c}" for i, c in enumerate(choices))
|
|
65
|
+
prompt = _CHOOSE_PROMPT.format(
|
|
66
|
+
question=question, options=options, prediction=prediction[:500]
|
|
67
|
+
)
|
|
68
|
+
verdict = await self._ask(prompt)
|
|
69
|
+
if isinstance(verdict, dict):
|
|
70
|
+
choice = verdict.get("choice")
|
|
71
|
+
if isinstance(choice, str) and len(choice) == 1 and "A" <= choice.upper() <= "H":
|
|
72
|
+
idx = ord(choice.upper()[0]) - 65
|
|
73
|
+
if idx < len(choices):
|
|
74
|
+
return idx
|
|
75
|
+
return None
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Model registry: load config/models.yaml and build adapter instances."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
from .base import BaseVLModel, ModelConfig
|
|
9
|
+
from .mock import MockModel
|
|
10
|
+
|
|
11
|
+
PROVIDERS: dict[str, type[BaseVLModel]] = {}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _register(cls: type[BaseVLModel]) -> type[BaseVLModel]:
|
|
15
|
+
PROVIDERS[cls.provider_name] = cls
|
|
16
|
+
return cls
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _build_providers() -> None:
|
|
20
|
+
from .anthropic_client import AnthropicModel
|
|
21
|
+
from .gemini_client import GeminiModel
|
|
22
|
+
from .ollama_client import OllamaModel
|
|
23
|
+
from .openai_client import OpenAICompatModel
|
|
24
|
+
|
|
25
|
+
for cls in (OpenAICompatModel, AnthropicModel, GeminiModel, OllamaModel, MockModel):
|
|
26
|
+
if cls.provider_name not in PROVIDERS:
|
|
27
|
+
PROVIDERS[cls.provider_name] = cls
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_registry(path: str | Path | None = None) -> dict[str, ModelConfig]:
|
|
31
|
+
_build_providers()
|
|
32
|
+
if path is None:
|
|
33
|
+
path = Path(__file__).resolve().parents[2] / "config" / "models.yaml"
|
|
34
|
+
path = Path(path)
|
|
35
|
+
if not path.exists():
|
|
36
|
+
raise FileNotFoundError(f"model registry not found: {path}")
|
|
37
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
38
|
+
registry: dict[str, ModelConfig] = {}
|
|
39
|
+
for name, spec in raw.get("models", {}).items():
|
|
40
|
+
if spec is None:
|
|
41
|
+
continue
|
|
42
|
+
if spec.get("enabled") is False:
|
|
43
|
+
continue
|
|
44
|
+
spec = {k: v for k, v in spec.items() if k != "enabled"}
|
|
45
|
+
provider = spec.get("provider")
|
|
46
|
+
if provider not in PROVIDERS:
|
|
47
|
+
raise ValueError(
|
|
48
|
+
f"model '{name}': unknown provider '{provider}', "
|
|
49
|
+
f"expected one of {sorted(PROVIDERS)}"
|
|
50
|
+
)
|
|
51
|
+
cfg = ModelConfig(name=name, **spec)
|
|
52
|
+
registry[name] = cfg
|
|
53
|
+
return registry
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_model(name: str, registry: dict[str, ModelConfig]) -> BaseVLModel:
|
|
57
|
+
_build_providers()
|
|
58
|
+
if name not in registry:
|
|
59
|
+
raise KeyError(f"model '{name}' not in registry; available: {sorted(registry)}")
|
|
60
|
+
cfg = registry[name]
|
|
61
|
+
return PROVIDERS[cfg.provider](cfg)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Anthropic Messages API adapter (Claude models), called via httpx."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from ..images import b64_image
|
|
9
|
+
from .base import BaseVLModel, ModelResponse
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AnthropicModel(BaseVLModel):
|
|
13
|
+
provider_name = "anthropic"
|
|
14
|
+
|
|
15
|
+
async def chat(self, image_path: str | None, prompt: str) -> ModelResponse:
|
|
16
|
+
blocks: list[dict] = []
|
|
17
|
+
if image_path:
|
|
18
|
+
b64, mime = b64_image(image_path)
|
|
19
|
+
blocks.append(
|
|
20
|
+
{"type": "image", "source": {"type": "base64", "media_type": mime, "data": b64}}
|
|
21
|
+
)
|
|
22
|
+
blocks.append({"type": "text", "text": prompt})
|
|
23
|
+
base = (self.cfg.resolve_base_url() or "https://api.anthropic.com").rstrip("/")
|
|
24
|
+
start = time.perf_counter()
|
|
25
|
+
async with httpx.AsyncClient(timeout=self.cfg.timeout) as client:
|
|
26
|
+
resp = await client.post(
|
|
27
|
+
f"{base}/v1/messages",
|
|
28
|
+
headers={
|
|
29
|
+
"x-api-key": self.cfg.resolve_api_key() or "",
|
|
30
|
+
"anthropic-version": "2023-06-01",
|
|
31
|
+
"content-type": "application/json",
|
|
32
|
+
},
|
|
33
|
+
json={
|
|
34
|
+
"model": self.cfg.model,
|
|
35
|
+
"max_tokens": self.cfg.max_tokens,
|
|
36
|
+
"temperature": self.cfg.temperature,
|
|
37
|
+
"messages": [{"role": "user", "content": blocks}],
|
|
38
|
+
},
|
|
39
|
+
)
|
|
40
|
+
resp.raise_for_status()
|
|
41
|
+
data = resp.json()
|
|
42
|
+
latency = int((time.perf_counter() - start) * 1000)
|
|
43
|
+
text = "".join(b.get("text", "") for b in data.get("content", [])).strip()
|
|
44
|
+
usage = data.get("usage") or {}
|
|
45
|
+
in_tok = usage.get("input_tokens")
|
|
46
|
+
out_tok = usage.get("output_tokens")
|
|
47
|
+
tokens = (in_tok or 0) + (out_tok or 0) or None
|
|
48
|
+
return ModelResponse(
|
|
49
|
+
text=text, latency_ms=latency, tokens=tokens,
|
|
50
|
+
input_tokens=in_tok, output_tokens=out_tok,
|
|
51
|
+
)
|
vlmeter/models/base.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Model adapter interface and shared call-result type."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ModelResponse:
|
|
11
|
+
text: str
|
|
12
|
+
latency_ms: int = 0
|
|
13
|
+
tokens: int | None = None # total (fallback when no split)
|
|
14
|
+
input_tokens: int | None = None
|
|
15
|
+
output_tokens: int | None = None
|
|
16
|
+
finish_reason: str | None = None
|
|
17
|
+
raw: Any = field(default=None, repr=False)
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def ok(self) -> bool:
|
|
21
|
+
return bool(self.text.strip())
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ModelConfig:
|
|
26
|
+
name: str # registry key / display name
|
|
27
|
+
provider: str # openai | anthropic | gemini | ollama | mock
|
|
28
|
+
model: str # provider-side model id
|
|
29
|
+
base_url: str | None = None
|
|
30
|
+
base_url_env: str | None = None # read base_url from this env var (overrides base_url)
|
|
31
|
+
api_key_env: str | None = None
|
|
32
|
+
api_key_default: str = "EMPTY" # used when the env var is unset (vLLM ignores keys)
|
|
33
|
+
max_tokens: int = 2048
|
|
34
|
+
temperature: float = 0.0
|
|
35
|
+
timeout: float = 120.0
|
|
36
|
+
concurrency: int = 4
|
|
37
|
+
price_input: float | None = None # 元 / 每百万输入 tokens(按官网价核对)
|
|
38
|
+
price_output: float | None = None # 元 / 每百万输出 tokens
|
|
39
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
40
|
+
|
|
41
|
+
def estimate_cost(self, input_tokens: int, output_tokens: int) -> float | None:
|
|
42
|
+
"""估算单次调用成本(元);未配置单价返回 None。"""
|
|
43
|
+
if self.price_input is None or self.price_output is None:
|
|
44
|
+
return None
|
|
45
|
+
return (input_tokens * self.price_input + output_tokens * self.price_output) / 1_000_000
|
|
46
|
+
|
|
47
|
+
def resolve_base_url(self) -> str | None:
|
|
48
|
+
if self.base_url_env:
|
|
49
|
+
return os.environ.get(self.base_url_env) or self.base_url
|
|
50
|
+
return self.base_url
|
|
51
|
+
|
|
52
|
+
def resolve_api_key(self) -> str | None:
|
|
53
|
+
if self.api_key_env:
|
|
54
|
+
return os.environ.get(self.api_key_env) or self.api_key_default
|
|
55
|
+
return self.api_key_default
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class BaseVLModel:
|
|
59
|
+
"""A vision-language chat model. Implementations must be safe to share
|
|
60
|
+
across asyncio tasks (create per-call clients or reuse an httpx client)."""
|
|
61
|
+
|
|
62
|
+
provider_name: str = "base"
|
|
63
|
+
|
|
64
|
+
def __init__(self, cfg: ModelConfig):
|
|
65
|
+
self.cfg = cfg
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def name(self) -> str:
|
|
69
|
+
return self.cfg.name
|
|
70
|
+
|
|
71
|
+
async def chat(self, image_path: str | None, prompt: str) -> ModelResponse:
|
|
72
|
+
raise NotImplementedError
|