auditkit 1.0.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.
- auditkit/README.md +99 -0
- auditkit/__init__.py +177 -0
- auditkit/__main__.py +3 -0
- auditkit/_bootstrap.py +77 -0
- auditkit/_identity_guard.py +99 -0
- auditkit/adapter.py +264 -0
- auditkit/annotator.py +339 -0
- auditkit/api.py +502 -0
- auditkit/assets/auditkit_logo.png +0 -0
- auditkit/cache.py +47 -0
- auditkit/cli.py +417 -0
- auditkit/comparison.py +563 -0
- auditkit/diff.py +265 -0
- auditkit/errors.py +54 -0
- auditkit/evaluator.py +20 -0
- auditkit/experiment.py +145 -0
- auditkit/hf_publish.py +262 -0
- auditkit/lmeval_engine.py +550 -0
- auditkit/loaders.py +121 -0
- auditkit/logs.py +18 -0
- auditkit/metric.py +199 -0
- auditkit/metrics/README.md +15 -0
- auditkit/metrics/__init__.py +0 -0
- auditkit/metrics/code.py +222 -0
- auditkit/metrics/embedding.py +131 -0
- auditkit/metrics/encoder_judge.py +423 -0
- auditkit/metrics/generation.py +331 -0
- auditkit/metrics/guard.py +412 -0
- auditkit/metrics/hallucination.py +45 -0
- auditkit/metrics/judge.py +547 -0
- auditkit/metrics/pairwise.py +153 -0
- auditkit/metrics/perf.py +53 -0
- auditkit/metrics/rag.py +149 -0
- auditkit/metrics/security.py +64 -0
- auditkit/metrics/toxicity.py +238 -0
- auditkit/model/README.md +16 -0
- auditkit/model/__init__.py +485 -0
- auditkit/model/anthropic.py +94 -0
- auditkit/model/api_gen.py +133 -0
- auditkit/model/groq_gen.py +121 -0
- auditkit/model/hf_gen.py +385 -0
- auditkit/model/lexsi.py +155 -0
- auditkit/model/litellm_gen.py +65 -0
- auditkit/model/openai.py +90 -0
- auditkit/model/openrouter_gen.py +152 -0
- auditkit/model/vllm_gen.py +316 -0
- auditkit/model_compare.py +655 -0
- auditkit/redteam/README.md +9 -0
- auditkit/redteam/__init__.py +26 -0
- auditkit/redteam/detector.py +37 -0
- auditkit/redteam/detectors/README.md +5 -0
- auditkit/redteam/detectors/builtin.py +126 -0
- auditkit/redteam/probe.py +39 -0
- auditkit/redteam/probes/README.md +5 -0
- auditkit/redteam/probes/builtin.py +85 -0
- auditkit/redteam/runner.py +206 -0
- auditkit/registry.py +65 -0
- auditkit/report.py +278 -0
- auditkit/report_format.py +52 -0
- auditkit/router.py +54 -0
- auditkit/runner.py +575 -0
- auditkit/runspec.py +159 -0
- auditkit/sample.py +40 -0
- auditkit/scenario.py +88 -0
- auditkit/scenarios/README.md +10 -0
- auditkit/scenarios/__init__.py +4 -0
- auditkit/scenarios/arc.py +33 -0
- auditkit/scenarios/gsm8k.py +32 -0
- auditkit/scenarios/hellaswag.py +33 -0
- auditkit/scenarios/humaneval.py +32 -0
- auditkit/scenarios/mmlu.py +34 -0
- auditkit/scenarios/truthfulqa.py +33 -0
- auditkit/score.py +165 -0
- auditkit/scorers.py +117 -0
- auditkit/scoring.py +79 -0
- auditkit/types.py +69 -0
- auditkit-1.0.0.dist-info/METADATA +396 -0
- auditkit-1.0.0.dist-info/RECORD +81 -0
- auditkit-1.0.0.dist-info/WHEEL +4 -0
- auditkit-1.0.0.dist-info/entry_points.txt +2 -0
- auditkit-1.0.0.dist-info/licenses/LICENSE.md +92 -0
auditkit/model/lexsi.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Lexsi platform model backend with optional MLflow auto-logging."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from . import (
|
|
8
|
+
Model, Request, Result_, Generated, resolve_params, DEFAULT_TEMPERATURE,
|
|
9
|
+
reject_generation_kwargs, split_session_kwargs,
|
|
10
|
+
)
|
|
11
|
+
from ..errors import ExtraNotInstalled
|
|
12
|
+
|
|
13
|
+
# The Lexsi platform's real accepted parameter set beyond temperature/
|
|
14
|
+
# max_tokens isn't documented in this codebase -- kept deliberately
|
|
15
|
+
# conservative (only what was already hardcoded here) rather than guessing
|
|
16
|
+
# at unverified field names for a proprietary API.
|
|
17
|
+
_KEY_MAP = {
|
|
18
|
+
"temperature": "temperature",
|
|
19
|
+
"max_tokens": "max_tokens",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LexsiModel(Model):
|
|
24
|
+
"""Call the Lexsi platform API with optional MLflow tracking.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
model
|
|
29
|
+
Model name on the Lexsi platform.
|
|
30
|
+
api_key
|
|
31
|
+
Lexsi API key. Falls back to ``LEXSI_API_KEY`` env var.
|
|
32
|
+
api_base
|
|
33
|
+
Base URL for the Lexsi API.
|
|
34
|
+
mlflow_tracking_uri
|
|
35
|
+
If set, auto-log every run to MLflow.
|
|
36
|
+
mlflow_experiment
|
|
37
|
+
MLflow experiment name (defaults to "auditkit-lexsi").
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
name = "lexsi"
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
model: str = "lexsi-3.5",
|
|
45
|
+
api_key: str | None = None,
|
|
46
|
+
api_base: str = "https://api.lexsi.ai/v1",
|
|
47
|
+
mlflow_tracking_uri: str | None = None,
|
|
48
|
+
mlflow_experiment: str | None = None,
|
|
49
|
+
name: str = "lexsi",
|
|
50
|
+
**kwargs: Any,
|
|
51
|
+
) -> None:
|
|
52
|
+
reject_generation_kwargs(kwargs, "LexsiModel")
|
|
53
|
+
self._model_name = model
|
|
54
|
+
self._api_key = api_key or os.environ.get("LEXSI_API_KEY")
|
|
55
|
+
self._api_base = api_base.rstrip("/")
|
|
56
|
+
self._mlflow_tracking_uri = mlflow_tracking_uri
|
|
57
|
+
self._mlflow_experiment = mlflow_experiment or "auditkit-lexsi"
|
|
58
|
+
# verify=/proxies=/cert=/timeout= are real requests.Session concerns,
|
|
59
|
+
# not API request-body fields -- split them out so e.g.
|
|
60
|
+
# LexsiModel(verify=False) actually disables TLS verification
|
|
61
|
+
# instead of silently becoming an extra, ignored JSON field.
|
|
62
|
+
self._session_kwargs, self._extra_kwargs = split_session_kwargs(kwargs)
|
|
63
|
+
self._session = None
|
|
64
|
+
self.name = name
|
|
65
|
+
self._total_requests = 0
|
|
66
|
+
self._total_latency_ms = 0.0
|
|
67
|
+
|
|
68
|
+
def _ensure_session(self) -> None:
|
|
69
|
+
if self._session is not None:
|
|
70
|
+
return
|
|
71
|
+
try:
|
|
72
|
+
import requests
|
|
73
|
+
except ImportError:
|
|
74
|
+
raise ExtraNotInstalled("requests", "pip install requests")
|
|
75
|
+
self._session = requests.Session()
|
|
76
|
+
self._session.headers.update({
|
|
77
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
78
|
+
"Content-Type": "application/json",
|
|
79
|
+
})
|
|
80
|
+
for attr in ("verify", "proxies", "cert"):
|
|
81
|
+
if attr in self._session_kwargs:
|
|
82
|
+
setattr(self._session, attr, self._session_kwargs[attr])
|
|
83
|
+
|
|
84
|
+
def generate(self, requests: list[Request]) -> list[Result_]:
|
|
85
|
+
self._ensure_session()
|
|
86
|
+
if self._api_key is None:
|
|
87
|
+
raise ValueError("Lexsi API key required (pass api_key= or set LEXSI_API_KEY)")
|
|
88
|
+
|
|
89
|
+
mlflow_active = False
|
|
90
|
+
if self._mlflow_tracking_uri:
|
|
91
|
+
try:
|
|
92
|
+
import mlflow
|
|
93
|
+
mlflow.set_tracking_uri(self._mlflow_tracking_uri)
|
|
94
|
+
mlflow.set_experiment(self._mlflow_experiment)
|
|
95
|
+
mlflow_active = True
|
|
96
|
+
except ImportError:
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
results = []
|
|
100
|
+
run_metrics = {}
|
|
101
|
+
defaults = {"temperature": DEFAULT_TEMPERATURE, "max_tokens": 1024}
|
|
102
|
+
last_gen_kwargs = dict(defaults)
|
|
103
|
+
|
|
104
|
+
for r in requests:
|
|
105
|
+
prompt = r.prompt if isinstance(r.prompt, str) else str(r.prompt)
|
|
106
|
+
last_gen_kwargs = resolve_params(r, defaults, _KEY_MAP)
|
|
107
|
+
import time
|
|
108
|
+
t0 = time.monotonic()
|
|
109
|
+
resp = self._session.post(
|
|
110
|
+
f"{self._api_base}/completions",
|
|
111
|
+
json={
|
|
112
|
+
"model": self._model_name,
|
|
113
|
+
"prompt": prompt,
|
|
114
|
+
**last_gen_kwargs,
|
|
115
|
+
**self._extra_kwargs,
|
|
116
|
+
},
|
|
117
|
+
timeout=self._session_kwargs.get("timeout", 120),
|
|
118
|
+
)
|
|
119
|
+
latency_ms = (time.monotonic() - t0) * 1000
|
|
120
|
+
self._total_requests += 1
|
|
121
|
+
self._total_latency_ms += latency_ms
|
|
122
|
+
try:
|
|
123
|
+
resp.raise_for_status()
|
|
124
|
+
except Exception as e:
|
|
125
|
+
# requests' default HTTPError message drops the response
|
|
126
|
+
# body, which is where the real API actually explains what's
|
|
127
|
+
# wrong. Surface it instead of a bare "400 Client Error".
|
|
128
|
+
raise type(e)(f"{e} -- response body: {resp.text}") from None
|
|
129
|
+
data = resp.json()
|
|
130
|
+
text = data.get("choices", [{}])[0].get("text", "")
|
|
131
|
+
results.append(Result_(completions=[Generated(text=text)], latency_ms=latency_ms))
|
|
132
|
+
|
|
133
|
+
if mlflow_active:
|
|
134
|
+
import mlflow
|
|
135
|
+
with mlflow.start_run(run_name=f"lexsi-batch-{self._total_requests}"):
|
|
136
|
+
mlflow.log_param("model", self._model_name)
|
|
137
|
+
mlflow.log_param("temperature", last_gen_kwargs.get("temperature", DEFAULT_TEMPERATURE))
|
|
138
|
+
mlflow.log_param("max_tokens", last_gen_kwargs.get("max_tokens", 1024))
|
|
139
|
+
mlflow.log_param("batch_size", len(requests))
|
|
140
|
+
mlflow.log_metric("total_requests", self._total_requests)
|
|
141
|
+
mlflow.log_metric("avg_latency_ms", self._total_latency_ms / max(self._total_requests, 1))
|
|
142
|
+
if run_metrics:
|
|
143
|
+
for k, v in run_metrics.items():
|
|
144
|
+
mlflow.log_metric(k, v)
|
|
145
|
+
|
|
146
|
+
return results
|
|
147
|
+
|
|
148
|
+
def log_mlflow_params(self, **params: Any) -> None:
|
|
149
|
+
"""Manually log parameters to the active MLflow run."""
|
|
150
|
+
try:
|
|
151
|
+
import mlflow
|
|
152
|
+
for k, v in params.items():
|
|
153
|
+
mlflow.log_param(k, v)
|
|
154
|
+
except ImportError:
|
|
155
|
+
pass
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""LiteLLM unified API backend (optional extra: auditkit[litellm])."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from . import (
|
|
7
|
+
Model, Request, Result_, Generated, resolve_params, resolve_messages,
|
|
8
|
+
DEFAULT_TEMPERATURE, reject_generation_kwargs,
|
|
9
|
+
)
|
|
10
|
+
from ..errors import ExtraNotInstalled
|
|
11
|
+
|
|
12
|
+
# LiteLLM normalizes most providers to OpenAI-style kwarg names.
|
|
13
|
+
_KEY_MAP = {
|
|
14
|
+
"temperature": "temperature",
|
|
15
|
+
"top_p": "top_p",
|
|
16
|
+
"max_tokens": "max_tokens",
|
|
17
|
+
"stop_sequences": "stop",
|
|
18
|
+
"presence_penalty": "presence_penalty",
|
|
19
|
+
"frequency_penalty": "frequency_penalty",
|
|
20
|
+
"num_completions": "n",
|
|
21
|
+
"seed": "seed",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LiteLLMModel(Model):
|
|
26
|
+
"""Call any LLM through the LiteLLM unified interface."""
|
|
27
|
+
|
|
28
|
+
name = "litellm"
|
|
29
|
+
threadsafe = True
|
|
30
|
+
|
|
31
|
+
def __init__(self, model: str = "gpt-4o-mini",
|
|
32
|
+
name: str = "litellm", **kwargs: Any) -> None:
|
|
33
|
+
reject_generation_kwargs(kwargs, "LiteLLMModel")
|
|
34
|
+
self._model_name = model
|
|
35
|
+
self._extra_kwargs = kwargs
|
|
36
|
+
self.name = name
|
|
37
|
+
|
|
38
|
+
def generate(self, requests: list[Request]) -> list[Result_]:
|
|
39
|
+
try:
|
|
40
|
+
from litellm import completion
|
|
41
|
+
except ImportError:
|
|
42
|
+
raise ExtraNotInstalled("litellm", "pip install auditkit[litellm]")
|
|
43
|
+
results = []
|
|
44
|
+
for r in requests:
|
|
45
|
+
defaults = {"temperature": DEFAULT_TEMPERATURE, "max_tokens": 1024}
|
|
46
|
+
gen_kwargs = resolve_params(r, defaults, _KEY_MAP)
|
|
47
|
+
resp = completion(
|
|
48
|
+
model=self._model_name,
|
|
49
|
+
messages=resolve_messages(r),
|
|
50
|
+
**gen_kwargs,
|
|
51
|
+
**self._extra_kwargs,
|
|
52
|
+
)
|
|
53
|
+
text = resp.choices[0].message.content or ""
|
|
54
|
+
usage = {}
|
|
55
|
+
resp_usage = getattr(resp, "usage", None)
|
|
56
|
+
if resp_usage is not None:
|
|
57
|
+
# LiteLLM normalizes every provider's response to this
|
|
58
|
+
# OpenAI-shaped usage object regardless of backend.
|
|
59
|
+
usage = {
|
|
60
|
+
"prompt_tokens": resp_usage.prompt_tokens,
|
|
61
|
+
"completion_tokens": resp_usage.completion_tokens,
|
|
62
|
+
"total_tokens": resp_usage.total_tokens,
|
|
63
|
+
}
|
|
64
|
+
results.append(Result_(completions=[Generated(text=text)], usage=usage))
|
|
65
|
+
return results
|
auditkit/model/openai.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""OpenAI model backend (optional extra: auditkit[openai])."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from . import (
|
|
7
|
+
Model, Request, Result_, Generated, resolve_params, resolve_messages,
|
|
8
|
+
DEFAULT_TEMPERATURE, reject_generation_kwargs,
|
|
9
|
+
)
|
|
10
|
+
from ..errors import ExtraNotInstalled
|
|
11
|
+
|
|
12
|
+
# AuditKit-internal RunConfig key -> OpenAI chat.completions.create() kwarg name.
|
|
13
|
+
# Only these keys are ever pulled from Request.params; timeout/max_retries/
|
|
14
|
+
# top_k/best_of are either Runner-only or unsupported by this API, so they're
|
|
15
|
+
# deliberately absent and can never reach the real API call.
|
|
16
|
+
_KEY_MAP = {
|
|
17
|
+
"temperature": "temperature",
|
|
18
|
+
"top_p": "top_p",
|
|
19
|
+
"max_tokens": "max_tokens",
|
|
20
|
+
"stop_sequences": "stop",
|
|
21
|
+
"presence_penalty": "presence_penalty",
|
|
22
|
+
"frequency_penalty": "frequency_penalty",
|
|
23
|
+
"num_completions": "n",
|
|
24
|
+
"seed": "seed",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
# Real openai.OpenAI() CLIENT-construction kwargs -- must reach the client
|
|
28
|
+
# constructor in _ensure_client(), not chat.completions.create(). Previously
|
|
29
|
+
# blindly spread into **self._extra_kwargs on the per-request call instead,
|
|
30
|
+
# where max_retries/default_headers/default_query/organization/project
|
|
31
|
+
# don't exist at all and raise TypeError (timeout happens to be valid on
|
|
32
|
+
# both, so it alone never surfaced this). Anything else in **kwargs is
|
|
33
|
+
# assumed to be a real chat.completions.create()-level kwarg and keeps
|
|
34
|
+
# going there, unaffected. api_key/base_url are already handled via their
|
|
35
|
+
# own explicit constructor params, not this set.
|
|
36
|
+
_CLIENT_KWARGS = frozenset({
|
|
37
|
+
"timeout", "max_retries", "default_headers", "default_query",
|
|
38
|
+
"organization", "project",
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class OpenAIModel(Model):
|
|
43
|
+
"""Call the OpenAI chat completions API."""
|
|
44
|
+
|
|
45
|
+
name = "openai"
|
|
46
|
+
|
|
47
|
+
def __init__(self, model: str = "gpt-4o-mini", api_key: str | None = None,
|
|
48
|
+
api_base: str | None = None, name: str = "openai", **kwargs: Any) -> None:
|
|
49
|
+
reject_generation_kwargs(kwargs, "OpenAIModel")
|
|
50
|
+
self._model_name = model
|
|
51
|
+
self._extra_kwargs = kwargs
|
|
52
|
+
self._client = None
|
|
53
|
+
self._api_key = api_key
|
|
54
|
+
self._api_base = api_base
|
|
55
|
+
self.name = name
|
|
56
|
+
|
|
57
|
+
def _ensure_client(self) -> None:
|
|
58
|
+
if self._client is not None:
|
|
59
|
+
return
|
|
60
|
+
try:
|
|
61
|
+
import openai
|
|
62
|
+
except ImportError:
|
|
63
|
+
raise ExtraNotInstalled("openai", "pip install auditkit[openai]")
|
|
64
|
+
client_kwargs = {k: v for k, v in self._extra_kwargs.items() if k in _CLIENT_KWARGS}
|
|
65
|
+
self._client = openai.OpenAI(api_key=self._api_key, base_url=self._api_base, **client_kwargs)
|
|
66
|
+
|
|
67
|
+
def generate(self, requests: list[Request]) -> list[Result_]:
|
|
68
|
+
self._ensure_client()
|
|
69
|
+
results = []
|
|
70
|
+
for r in requests:
|
|
71
|
+
defaults = {"temperature": DEFAULT_TEMPERATURE, "max_tokens": 1024}
|
|
72
|
+
gen_kwargs = resolve_params(r, defaults, _KEY_MAP)
|
|
73
|
+
call_kwargs = {k: v for k, v in self._extra_kwargs.items() if k not in _CLIENT_KWARGS}
|
|
74
|
+
resp = self._client.chat.completions.create(
|
|
75
|
+
model=self._model_name,
|
|
76
|
+
messages=resolve_messages(r),
|
|
77
|
+
**gen_kwargs,
|
|
78
|
+
**call_kwargs,
|
|
79
|
+
)
|
|
80
|
+
text = resp.choices[0].message.content or ""
|
|
81
|
+
usage = {}
|
|
82
|
+
resp_usage = getattr(resp, "usage", None)
|
|
83
|
+
if resp_usage is not None:
|
|
84
|
+
usage = {
|
|
85
|
+
"prompt_tokens": resp_usage.prompt_tokens,
|
|
86
|
+
"completion_tokens": resp_usage.completion_tokens,
|
|
87
|
+
"total_tokens": resp_usage.total_tokens,
|
|
88
|
+
}
|
|
89
|
+
results.append(Result_(completions=[Generated(text=text)], usage=usage))
|
|
90
|
+
return results
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""OpenRouter model backend (optional extra: auditkit[requests]).
|
|
2
|
+
|
|
3
|
+
OpenRouter's API is a documented OpenAI-compatible endpoint
|
|
4
|
+
(https://openrouter.ai/api/v1, standard Bearer auth, /chat/completions
|
|
5
|
+
request/response shape) -- this backend is a thin, pre-configured wrapper so
|
|
6
|
+
you don't have to pass api_base=/api_key= through the generic api: backend
|
|
7
|
+
by hand every time. Same shape as GroqModel; OpenRouter itself is a router
|
|
8
|
+
in front of many providers (OpenAI, Anthropic, Meta, Mistral, Google, ...),
|
|
9
|
+
selected via a "provider/model" name (e.g. "openai/gpt-4o",
|
|
10
|
+
"anthropic/claude-3.5-sonnet", "meta-llama/llama-3.1-70b-instruct") rather
|
|
11
|
+
than one fixed model family.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import time
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from . import (
|
|
20
|
+
Model, Request, Result_, Generated, resolve_params, resolve_messages,
|
|
21
|
+
DEFAULT_TEMPERATURE, reject_generation_kwargs, split_session_kwargs,
|
|
22
|
+
)
|
|
23
|
+
from ..errors import ExtraNotInstalled
|
|
24
|
+
|
|
25
|
+
_KEY_MAP = {
|
|
26
|
+
"temperature": "temperature",
|
|
27
|
+
"top_p": "top_p",
|
|
28
|
+
"max_tokens": "max_tokens",
|
|
29
|
+
"stop_sequences": "stop",
|
|
30
|
+
"presence_penalty": "presence_penalty",
|
|
31
|
+
"frequency_penalty": "frequency_penalty",
|
|
32
|
+
"num_completions": "n",
|
|
33
|
+
"seed": "seed",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class OpenRouterModel(Model):
|
|
38
|
+
"""Call the OpenRouter chat completions API.
|
|
39
|
+
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
model
|
|
43
|
+
Model name on OpenRouter, in ``"provider/model"`` form (e.g.
|
|
44
|
+
``"openai/gpt-4o"``, ``"anthropic/claude-3.5-sonnet"``,
|
|
45
|
+
``"meta-llama/llama-3.1-70b-instruct"``). The provider prefix is
|
|
46
|
+
OpenRouter's own routing syntax, unrelated to AuditKit's
|
|
47
|
+
``"openrouter:"`` spec prefix -- ``"openrouter:openai/gpt-4o"``
|
|
48
|
+
parses correctly since only the text before the *first* colon is
|
|
49
|
+
AuditKit's own prefix.
|
|
50
|
+
api_key
|
|
51
|
+
OpenRouter API key. Falls back to the ``OPENROUTER_API_KEY`` env var.
|
|
52
|
+
api_base
|
|
53
|
+
Base URL (default: ``https://openrouter.ai/api/v1``).
|
|
54
|
+
site_url, app_name
|
|
55
|
+
Optional -- OpenRouter's own leaderboard/analytics attribution
|
|
56
|
+
headers (``HTTP-Referer``/``X-Title``). Not required for the API
|
|
57
|
+
to work; harmless to omit.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
name = "openrouter"
|
|
61
|
+
threadsafe = False
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
model: str = "openai/gpt-4o-mini",
|
|
66
|
+
api_key: str | None = None,
|
|
67
|
+
api_base: str = "https://openrouter.ai/api/v1",
|
|
68
|
+
site_url: str | None = None,
|
|
69
|
+
app_name: str | None = None,
|
|
70
|
+
name: str = "openrouter",
|
|
71
|
+
**kwargs: Any,
|
|
72
|
+
) -> None:
|
|
73
|
+
reject_generation_kwargs(kwargs, "OpenRouterModel")
|
|
74
|
+
self._model_name = model
|
|
75
|
+
self._api_base = api_base.rstrip("/")
|
|
76
|
+
self._api_key = api_key or os.environ.get("OPENROUTER_API_KEY")
|
|
77
|
+
self._site_url = site_url
|
|
78
|
+
self._app_name = app_name
|
|
79
|
+
# verify=/proxies=/cert=/timeout= are real requests.Session concerns,
|
|
80
|
+
# not API request-body fields -- split them out so e.g.
|
|
81
|
+
# OpenRouterModel(verify=False) actually disables TLS verification
|
|
82
|
+
# instead of silently becoming an extra, ignored JSON field.
|
|
83
|
+
self._session_kwargs, self._extra_kwargs = split_session_kwargs(kwargs)
|
|
84
|
+
self._session = None
|
|
85
|
+
self.name = name
|
|
86
|
+
|
|
87
|
+
def _ensure_session(self) -> None:
|
|
88
|
+
if self._session is not None:
|
|
89
|
+
return
|
|
90
|
+
try:
|
|
91
|
+
import requests
|
|
92
|
+
except ImportError:
|
|
93
|
+
raise ExtraNotInstalled("requests", "pip install auditkit[requests]")
|
|
94
|
+
self._session = requests.Session()
|
|
95
|
+
headers = {
|
|
96
|
+
"Authorization": f"Bearer {self._api_key}" if self._api_key else "",
|
|
97
|
+
"Content-Type": "application/json",
|
|
98
|
+
}
|
|
99
|
+
if self._site_url:
|
|
100
|
+
headers["HTTP-Referer"] = self._site_url
|
|
101
|
+
if self._app_name:
|
|
102
|
+
headers["X-Title"] = self._app_name
|
|
103
|
+
self._session.headers.update(headers)
|
|
104
|
+
for attr in ("verify", "proxies", "cert"):
|
|
105
|
+
if attr in self._session_kwargs:
|
|
106
|
+
setattr(self._session, attr, self._session_kwargs[attr])
|
|
107
|
+
|
|
108
|
+
def generate(self, requests: list[Request]) -> list[Result_]:
|
|
109
|
+
self._ensure_session()
|
|
110
|
+
results = []
|
|
111
|
+
for r in requests:
|
|
112
|
+
defaults = {"temperature": DEFAULT_TEMPERATURE, "max_tokens": 1024}
|
|
113
|
+
gen_kwargs = resolve_params(r, defaults, _KEY_MAP)
|
|
114
|
+
body = {
|
|
115
|
+
"model": self._model_name,
|
|
116
|
+
"messages": resolve_messages(r),
|
|
117
|
+
**gen_kwargs,
|
|
118
|
+
**self._extra_kwargs,
|
|
119
|
+
}
|
|
120
|
+
timeout = self._session_kwargs.get("timeout", 120)
|
|
121
|
+
t0 = time.monotonic()
|
|
122
|
+
resp = self._session.post(f"{self._api_base}/chat/completions", json=body, timeout=timeout)
|
|
123
|
+
try:
|
|
124
|
+
resp.raise_for_status()
|
|
125
|
+
except Exception as e:
|
|
126
|
+
# requests' default HTTPError message drops the response body,
|
|
127
|
+
# which is where OpenRouter (and the underlying provider it
|
|
128
|
+
# routed to) actually explains what's wrong -- an invalid
|
|
129
|
+
# model slug, an unsupported param for that specific model,
|
|
130
|
+
# rate limiting, etc. Surface it instead of a bare "400
|
|
131
|
+
# Client Error: Bad Request".
|
|
132
|
+
raise type(e)(f"{e} -- response body: {resp.text}") from None
|
|
133
|
+
latency_ms = (time.monotonic() - t0) * 1000
|
|
134
|
+
data = resp.json()
|
|
135
|
+
message = data.get("choices", [{}])[0].get("message", {}) or {}
|
|
136
|
+
# Reasoning models (e.g. some OpenRouter-routed models) can return
|
|
137
|
+
# an explicit `"content": null` -- not a missing key -- when
|
|
138
|
+
# max_tokens is hit while the model is still inside its internal
|
|
139
|
+
# `reasoning` field. dict.get(key, "") only falls back to "" when
|
|
140
|
+
# the key is *absent*, so a present-but-null value passes through
|
|
141
|
+
# as None otherwise; `or ""` catches both cases.
|
|
142
|
+
text = message.get("content") or ""
|
|
143
|
+
raw_usage = data.get("usage") or {}
|
|
144
|
+
usage = {}
|
|
145
|
+
if raw_usage:
|
|
146
|
+
usage = {
|
|
147
|
+
"prompt_tokens": raw_usage.get("prompt_tokens", 0),
|
|
148
|
+
"completion_tokens": raw_usage.get("completion_tokens", 0),
|
|
149
|
+
"total_tokens": raw_usage.get("total_tokens", 0),
|
|
150
|
+
}
|
|
151
|
+
results.append(Result_(completions=[Generated(text=text)], usage=usage, latency_ms=latency_ms))
|
|
152
|
+
return results
|