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 ADDED
@@ -0,0 +1,3 @@
1
+ """vlmeter — 多模态大模型准确率 × 成本计量流水线(vision-language meter)."""
2
+
3
+ __version__ = "0.2.0"
vlmeter/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
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