skillware-bench 0.3.2__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.
- skillware_bench/__init__.py +62 -0
- skillware_bench/cli.py +639 -0
- skillware_bench/core/adapters/__init__.py +50 -0
- skillware_bench/core/adapters/anthropic_adapter.py +166 -0
- skillware_bench/core/adapters/base.py +182 -0
- skillware_bench/core/adapters/factory.py +140 -0
- skillware_bench/core/adapters/gemini_adapter.py +179 -0
- skillware_bench/core/adapters/mistral_adapter.py +136 -0
- skillware_bench/core/adapters/mock_adapter.py +100 -0
- skillware_bench/core/adapters/ollama_adapter.py +137 -0
- skillware_bench/core/adapters/openai_adapter.py +164 -0
- skillware_bench/core/adapters/openrouter_adapter.py +446 -0
- skillware_bench/core/adapters/registry.py +8 -0
- skillware_bench/core/agent_loop.py +383 -0
- skillware_bench/core/evaluators.py +246 -0
- skillware_bench/core/models.py +96 -0
- skillware_bench/core/openrouter_engine.py +357 -0
- skillware_bench/core/perturbations.py +130 -0
- skillware_bench/core/provenance.py +371 -0
- skillware_bench/core/runner.py +147 -0
- skillware_bench/core/statistics.py +123 -0
- skillware_bench/dashboard/__init__.py +8 -0
- skillware_bench/dashboard/generator.py +248 -0
- skillware_bench/fixtures/__init__.py +9 -0
- skillware_bench/fixtures/mock_companies_registry.py +419 -0
- skillware_bench/fixtures/mock_inbox_registry.py +228 -0
- skillware_bench/infra/__init__.py +27 -0
- skillware_bench/infra/companies_house_live.py +228 -0
- skillware_bench/infra/disposable_mailbox.py +210 -0
- skillware_bench/infra/evm_testnet_fork.py +179 -0
- skillware_bench/infra/hermetic_mock_server.py +354 -0
- skillware_bench/infra/wallet_screening_live.py +143 -0
- skillware_bench/suites/__init__.py +33 -0
- skillware_bench/suites/chained_pipeline_crucible.py +177 -0
- skillware_bench/suites/companies_house_crucible.py +187 -0
- skillware_bench/suites/constitution_crucible.py +171 -0
- skillware_bench/suites/context_drift_crucible.py +254 -0
- skillware_bench/suites/distraction_registry.py +249 -0
- skillware_bench/suites/gmail_crucible.py +207 -0
- skillware_bench/suites/prompt_injection_crucible.py +263 -0
- skillware_bench/suites/wallet_screening_crucible.py +167 -0
- skillware_bench/viz/__init__.py +8 -0
- skillware_bench/viz/generator.py +179 -0
- skillware_bench-0.3.2.dist-info/METADATA +287 -0
- skillware_bench-0.3.2.dist-info/RECORD +49 -0
- skillware_bench-0.3.2.dist-info/WHEEL +5 -0
- skillware_bench-0.3.2.dist-info/entry_points.txt +2 -0
- skillware_bench-0.3.2.dist-info/licenses/LICENSE +21 -0
- skillware_bench-0.3.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# Copyright (c) 2026 ARPA Hellenic Logical Systems
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""Anthropic model adapter (Claude 3.7 Sonnet, Claude 3.5 Haiku)."""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from skillware_bench.core.adapters.base import (
|
|
11
|
+
BaseModelAdapter,
|
|
12
|
+
ModelResponse,
|
|
13
|
+
TokenUsage,
|
|
14
|
+
ToolCallRequest,
|
|
15
|
+
execute_with_retry,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from skillware.core.loader import SkillLoader
|
|
20
|
+
except ImportError:
|
|
21
|
+
SkillLoader = None # type: ignore
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AnthropicAdapter(BaseModelAdapter):
|
|
25
|
+
"""Adapter for Anthropic Claude models supporting structured tool use."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
model_name: str = "claude-3-7-sonnet-20250219",
|
|
30
|
+
api_key: Optional[str] = None,
|
|
31
|
+
base_url: Optional[str] = None,
|
|
32
|
+
**kwargs: Any,
|
|
33
|
+
):
|
|
34
|
+
super().__init__(model_name=model_name, api_key=api_key, **kwargs)
|
|
35
|
+
self.api_key = api_key or os.environ.get("ANTHROPIC_API_KEY")
|
|
36
|
+
self.base_url = base_url or os.environ.get("ANTHROPIC_BASE_URL")
|
|
37
|
+
self._client = None
|
|
38
|
+
|
|
39
|
+
def _get_client(self):
|
|
40
|
+
if self._client is None:
|
|
41
|
+
try:
|
|
42
|
+
import anthropic
|
|
43
|
+
|
|
44
|
+
self._client = anthropic.Anthropic(
|
|
45
|
+
api_key=self.api_key,
|
|
46
|
+
base_url=self.base_url,
|
|
47
|
+
timeout=self.timeout_seconds,
|
|
48
|
+
)
|
|
49
|
+
except ImportError:
|
|
50
|
+
raise ImportError(
|
|
51
|
+
"anthropic package is required to use AnthropicAdapter. Run: pip install anthropic"
|
|
52
|
+
)
|
|
53
|
+
return self._client
|
|
54
|
+
|
|
55
|
+
def format_tools(self, tool_definitions: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
56
|
+
formatted: List[Dict[str, Any]] = []
|
|
57
|
+
for tool in tool_definitions:
|
|
58
|
+
if "name" in tool and "input_schema" in tool:
|
|
59
|
+
formatted.append(tool)
|
|
60
|
+
elif "manifest" in tool and SkillLoader is not None:
|
|
61
|
+
formatted.append(SkillLoader.to_claude_tool(tool))
|
|
62
|
+
else:
|
|
63
|
+
name = tool.get("name", "unknown_tool")
|
|
64
|
+
desc = tool.get("description", "")
|
|
65
|
+
params = tool.get("parameters") or tool.get("input_schema") or {}
|
|
66
|
+
formatted.append(
|
|
67
|
+
{
|
|
68
|
+
"name": name,
|
|
69
|
+
"description": desc,
|
|
70
|
+
"input_schema": params,
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
return formatted
|
|
74
|
+
|
|
75
|
+
def chat(
|
|
76
|
+
self,
|
|
77
|
+
messages: List[Dict[str, Any]],
|
|
78
|
+
tools: Optional[List[Dict[str, Any]]] = None,
|
|
79
|
+
system_prompt: Optional[str] = None,
|
|
80
|
+
**kwargs: Any,
|
|
81
|
+
) -> ModelResponse:
|
|
82
|
+
client = self._get_client()
|
|
83
|
+
|
|
84
|
+
anthropic_messages: List[Dict[str, Any]] = []
|
|
85
|
+
for m in messages:
|
|
86
|
+
role = m.get("role", "user")
|
|
87
|
+
if role == "system":
|
|
88
|
+
if not system_prompt:
|
|
89
|
+
system_prompt = m.get("content", "")
|
|
90
|
+
continue
|
|
91
|
+
anthropic_messages.append({"role": role, "content": m.get("content", "")})
|
|
92
|
+
|
|
93
|
+
payload: Dict[str, Any] = {
|
|
94
|
+
"model": self.model_name,
|
|
95
|
+
"messages": anthropic_messages,
|
|
96
|
+
"max_tokens": self.max_tokens or 2048,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if self.temperature is not None:
|
|
100
|
+
payload["temperature"] = self.temperature
|
|
101
|
+
|
|
102
|
+
if system_prompt:
|
|
103
|
+
payload["system"] = system_prompt
|
|
104
|
+
|
|
105
|
+
if tools:
|
|
106
|
+
payload["tools"] = self.format_tools(tools)
|
|
107
|
+
|
|
108
|
+
t0 = time.perf_counter()
|
|
109
|
+
|
|
110
|
+
def _call_api():
|
|
111
|
+
return client.messages.create(**payload)
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
import anthropic
|
|
115
|
+
|
|
116
|
+
retry_types = (
|
|
117
|
+
anthropic.RateLimitError,
|
|
118
|
+
anthropic.APIConnectionError,
|
|
119
|
+
anthropic.InternalServerError,
|
|
120
|
+
)
|
|
121
|
+
except Exception:
|
|
122
|
+
retry_types = (Exception,)
|
|
123
|
+
|
|
124
|
+
response = execute_with_retry(_call_api, retryable_exceptions=retry_types)
|
|
125
|
+
latency_ms = (time.perf_counter() - t0) * 1000.0
|
|
126
|
+
|
|
127
|
+
content_parts = []
|
|
128
|
+
tool_calls: List[ToolCallRequest] = []
|
|
129
|
+
|
|
130
|
+
for block in response.content:
|
|
131
|
+
if getattr(block, "type", "") == "text":
|
|
132
|
+
content_parts.append(block.text)
|
|
133
|
+
elif getattr(block, "type", "") == "tool_use":
|
|
134
|
+
tool_calls.append(
|
|
135
|
+
ToolCallRequest(
|
|
136
|
+
name=block.name,
|
|
137
|
+
arguments=block.input if isinstance(block.input, dict) else {},
|
|
138
|
+
id=block.id,
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
content = "\n".join(content_parts)
|
|
143
|
+
|
|
144
|
+
cached_tokens = 0
|
|
145
|
+
if hasattr(response.usage, "cache_read_input_tokens"):
|
|
146
|
+
cached_tokens += getattr(response.usage, "cache_read_input_tokens", 0) or 0
|
|
147
|
+
if hasattr(response.usage, "cache_creation_input_tokens"):
|
|
148
|
+
cached_tokens += getattr(response.usage, "cache_creation_input_tokens", 0) or 0
|
|
149
|
+
|
|
150
|
+
usage = TokenUsage(
|
|
151
|
+
prompt_tokens=response.usage.input_tokens,
|
|
152
|
+
completion_tokens=response.usage.output_tokens,
|
|
153
|
+
cached_tokens=cached_tokens,
|
|
154
|
+
total_tokens=response.usage.input_tokens + response.usage.output_tokens,
|
|
155
|
+
)
|
|
156
|
+
self.record_usage(usage)
|
|
157
|
+
|
|
158
|
+
return ModelResponse(
|
|
159
|
+
content=content,
|
|
160
|
+
tool_calls=tool_calls,
|
|
161
|
+
usage=usage,
|
|
162
|
+
latency_ms=latency_ms,
|
|
163
|
+
ttft_ms=latency_ms * 0.40,
|
|
164
|
+
model=self.model_name,
|
|
165
|
+
raw=response,
|
|
166
|
+
)
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# Copyright (c) 2026 ARPA Hellenic Logical Systems
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""Base model adapter interface, precision token accounting, pricing, and retry engine."""
|
|
5
|
+
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
import random
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple, Type
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ToolCallRequest:
|
|
15
|
+
"""Represents a structured tool/function call emitted by an LLM."""
|
|
16
|
+
|
|
17
|
+
name: str
|
|
18
|
+
arguments: Dict[str, Any] = field(default_factory=dict)
|
|
19
|
+
id: str = ""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class TokenUsage:
|
|
24
|
+
"""Precision token telemetry including prompt, completion, and prefix caching."""
|
|
25
|
+
|
|
26
|
+
prompt_tokens: int = 0
|
|
27
|
+
completion_tokens: int = 0
|
|
28
|
+
cached_tokens: int = 0
|
|
29
|
+
total_tokens: int = 0
|
|
30
|
+
cost_usd: float = 0.0
|
|
31
|
+
|
|
32
|
+
def __post_init__(self):
|
|
33
|
+
if self.total_tokens == 0:
|
|
34
|
+
self.total_tokens = self.prompt_tokens + self.completion_tokens
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class ModelResponse:
|
|
39
|
+
"""Standardized response across all LLM provider adapters."""
|
|
40
|
+
|
|
41
|
+
content: str
|
|
42
|
+
tool_calls: List[ToolCallRequest] = field(default_factory=list)
|
|
43
|
+
usage: TokenUsage = field(default_factory=TokenUsage)
|
|
44
|
+
latency_ms: float = 0.0
|
|
45
|
+
ttft_ms: float = 0.0
|
|
46
|
+
model: str = ""
|
|
47
|
+
raw: Any = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# Pricing matrix (USD per million tokens): (prompt, completion, cached_prompt)
|
|
51
|
+
MODEL_PRICING_TABLE: Dict[str, Tuple[float, float, float]] = {
|
|
52
|
+
# OpenAI
|
|
53
|
+
"gpt-5": (5.00, 20.00, 2.50),
|
|
54
|
+
"gpt-4.5": (10.00, 40.00, 5.00),
|
|
55
|
+
"gpt-4o": (2.50, 10.00, 1.25),
|
|
56
|
+
"gpt-4o-mini": (0.15, 0.60, 0.075),
|
|
57
|
+
"o3-mini": (1.10, 4.40, 0.55),
|
|
58
|
+
"o1": (15.00, 60.00, 7.50),
|
|
59
|
+
# Anthropic
|
|
60
|
+
"claude-sonnet-5": (3.00, 15.00, 0.30),
|
|
61
|
+
"sonnet-5": (3.00, 15.00, 0.30),
|
|
62
|
+
"claude-5": (3.00, 15.00, 0.30),
|
|
63
|
+
"claude-3-7-sonnet": (3.00, 15.00, 0.30),
|
|
64
|
+
"claude-3-5-sonnet": (3.00, 15.00, 0.30),
|
|
65
|
+
"claude-3-5-haiku": (0.80, 4.00, 0.08),
|
|
66
|
+
# Google Gemini
|
|
67
|
+
"gemini-3.8-pro": (1.25, 5.00, 0.3125),
|
|
68
|
+
"gemini-3.8-flash": (0.075, 0.30, 0.01875),
|
|
69
|
+
"gemini-3.8": (0.075, 0.30, 0.01875),
|
|
70
|
+
"gemini-3-flash": (0.075, 0.30, 0.01875),
|
|
71
|
+
"gemini-2.5-pro": (1.25, 5.00, 0.3125),
|
|
72
|
+
"gemini-2.5-flash": (0.075, 0.30, 0.01875),
|
|
73
|
+
"gemini-1.5-pro": (1.25, 5.00, 0.3125),
|
|
74
|
+
"gemini-1.5-flash": (0.075, 0.30, 0.01875),
|
|
75
|
+
# Mistral
|
|
76
|
+
"mistral-large-3": (2.00, 6.00, 0.0),
|
|
77
|
+
"mistral-large": (2.00, 6.00, 0.0),
|
|
78
|
+
"codestral": (0.30, 0.90, 0.0),
|
|
79
|
+
# Local Sovereign (Zero API Cost)
|
|
80
|
+
"llama3.3": (0.0, 0.0, 0.0),
|
|
81
|
+
"llama3.2": (0.0, 0.0, 0.0),
|
|
82
|
+
"llama3": (0.0, 0.0, 0.0),
|
|
83
|
+
"qwen2.5": (0.0, 0.0, 0.0),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def calculate_cost(model_name: str, usage: TokenUsage) -> float:
|
|
88
|
+
"""Compute exact USD cost based on token telemetry and model pricing tier."""
|
|
89
|
+
name_lower = model_name.lower()
|
|
90
|
+
rates: Optional[Tuple[float, float, float]] = None
|
|
91
|
+
|
|
92
|
+
for key, val in MODEL_PRICING_TABLE.items():
|
|
93
|
+
if key in name_lower:
|
|
94
|
+
rates = val
|
|
95
|
+
break
|
|
96
|
+
|
|
97
|
+
if rates is None:
|
|
98
|
+
rates = (1.00, 3.00, 0.0)
|
|
99
|
+
|
|
100
|
+
prompt_rate, completion_rate, cached_rate = rates
|
|
101
|
+
uncached_prompt = max(0, usage.prompt_tokens - usage.cached_tokens)
|
|
102
|
+
|
|
103
|
+
cost = (
|
|
104
|
+
(uncached_prompt * prompt_rate / 1_000_000.0)
|
|
105
|
+
+ (usage.cached_tokens * cached_rate / 1_000_000.0)
|
|
106
|
+
+ (usage.completion_tokens * completion_rate / 1_000_000.0)
|
|
107
|
+
)
|
|
108
|
+
return round(cost, 6)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def execute_with_retry(
|
|
112
|
+
fn: Callable[[], Any],
|
|
113
|
+
max_retries: int = 4,
|
|
114
|
+
initial_delay: float = 1.0,
|
|
115
|
+
backoff_factor: float = 2.0,
|
|
116
|
+
jitter: bool = True,
|
|
117
|
+
retryable_exceptions: Tuple[Type[Exception], ...] = (Exception,),
|
|
118
|
+
) -> Any:
|
|
119
|
+
"""Execute callable with exponential backoff and jitter for resilient API calling."""
|
|
120
|
+
delay = initial_delay
|
|
121
|
+
last_err: Optional[Exception] = None
|
|
122
|
+
|
|
123
|
+
for attempt in range(max_retries + 1):
|
|
124
|
+
try:
|
|
125
|
+
return fn()
|
|
126
|
+
except retryable_exceptions as e:
|
|
127
|
+
last_err = e
|
|
128
|
+
if attempt == max_retries:
|
|
129
|
+
raise e
|
|
130
|
+
sleep_time = delay * (1.0 + (random.uniform(0, 0.25) if jitter else 0.0))
|
|
131
|
+
time.sleep(sleep_time)
|
|
132
|
+
delay *= backoff_factor
|
|
133
|
+
|
|
134
|
+
if last_err is not None:
|
|
135
|
+
raise last_err
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class BaseModelAdapter(ABC):
|
|
139
|
+
"""Abstract base adapter for frontier and sovereign LLM providers."""
|
|
140
|
+
|
|
141
|
+
def __init__(
|
|
142
|
+
self,
|
|
143
|
+
model_name: str,
|
|
144
|
+
api_key: Optional[str] = None,
|
|
145
|
+
temperature: float = 0.0,
|
|
146
|
+
max_tokens: Optional[int] = None,
|
|
147
|
+
timeout_seconds: float = 60.0,
|
|
148
|
+
**kwargs: Any,
|
|
149
|
+
):
|
|
150
|
+
self.model_name = model_name
|
|
151
|
+
self.api_key = api_key
|
|
152
|
+
self.temperature = temperature
|
|
153
|
+
self.max_tokens = max_tokens
|
|
154
|
+
self.timeout_seconds = timeout_seconds
|
|
155
|
+
self.extra_config = kwargs
|
|
156
|
+
|
|
157
|
+
self.cumulative_usage = TokenUsage()
|
|
158
|
+
self.cumulative_cost_usd: float = 0.0
|
|
159
|
+
|
|
160
|
+
@abstractmethod
|
|
161
|
+
def chat(
|
|
162
|
+
self,
|
|
163
|
+
messages: List[Dict[str, Any]],
|
|
164
|
+
tools: Optional[List[Dict[str, Any]]] = None,
|
|
165
|
+
system_prompt: Optional[str] = None,
|
|
166
|
+
**kwargs: Any,
|
|
167
|
+
) -> ModelResponse:
|
|
168
|
+
"""Send chat messages and optional tool definitions to the LLM."""
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
def format_tools(self, tool_definitions: List[Dict[str, Any]]) -> List[Any]:
|
|
172
|
+
"""Convert generic tool definitions into provider-native format."""
|
|
173
|
+
return tool_definitions
|
|
174
|
+
|
|
175
|
+
def record_usage(self, usage: TokenUsage) -> None:
|
|
176
|
+
"""Accumulate token accounting and compute incremental cost."""
|
|
177
|
+
usage.cost_usd = calculate_cost(self.model_name, usage)
|
|
178
|
+
self.cumulative_usage.prompt_tokens += usage.prompt_tokens
|
|
179
|
+
self.cumulative_usage.completion_tokens += usage.completion_tokens
|
|
180
|
+
self.cumulative_usage.cached_tokens += usage.cached_tokens
|
|
181
|
+
self.cumulative_usage.total_tokens += usage.total_tokens
|
|
182
|
+
self.cumulative_cost_usd += usage.cost_usd
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# Copyright (c) 2026 ARPA Hellenic Logical Systems
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""Adapter factory and Bring-Your-Own-Model (BYOM) registry."""
|
|
5
|
+
|
|
6
|
+
from typing import Any, Dict, Optional, Type
|
|
7
|
+
|
|
8
|
+
from skillware_bench.core.adapters.anthropic_adapter import AnthropicAdapter
|
|
9
|
+
from skillware_bench.core.adapters.base import BaseModelAdapter
|
|
10
|
+
from skillware_bench.core.adapters.gemini_adapter import GeminiAdapter
|
|
11
|
+
from skillware_bench.core.adapters.mistral_adapter import MistralAdapter
|
|
12
|
+
from skillware_bench.core.adapters.mock_adapter import MockModelAdapter
|
|
13
|
+
from skillware_bench.core.adapters.ollama_adapter import OllamaAdapter
|
|
14
|
+
from skillware_bench.core.adapters.openai_adapter import OpenAIAdapter
|
|
15
|
+
from skillware_bench.core.adapters.openrouter_adapter import OpenRouterAdapter
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AdapterRegistry:
|
|
19
|
+
"""Registry allowing users to plug in custom model adapters for BYOM evaluation."""
|
|
20
|
+
|
|
21
|
+
_custom_adapters: Dict[str, Type[BaseModelAdapter]] = {}
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def register(cls, model_prefix: str, adapter_cls: Type[BaseModelAdapter]) -> None:
|
|
25
|
+
"""Register a custom adapter class for a model identifier prefix."""
|
|
26
|
+
cls._custom_adapters[model_prefix.lower()] = adapter_cls
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def get_custom(cls, model_name: str) -> Optional[Type[BaseModelAdapter]]:
|
|
30
|
+
name_lower = model_name.lower()
|
|
31
|
+
for prefix, adapter_cls in cls._custom_adapters.items():
|
|
32
|
+
if name_lower.startswith(prefix):
|
|
33
|
+
return adapter_cls
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_adapter(
|
|
38
|
+
model_name: str,
|
|
39
|
+
mock: bool = False,
|
|
40
|
+
adapter_path: Optional[str] = None,
|
|
41
|
+
**kwargs: Any,
|
|
42
|
+
) -> BaseModelAdapter:
|
|
43
|
+
"""Resolve and instantiate the appropriate model adapter."""
|
|
44
|
+
if adapter_path:
|
|
45
|
+
return load_adapter_from_file(adapter_path, model_name=model_name, **kwargs)
|
|
46
|
+
|
|
47
|
+
if mock:
|
|
48
|
+
return MockModelAdapter(model_name=model_name, **kwargs)
|
|
49
|
+
|
|
50
|
+
name_lower = model_name.lower()
|
|
51
|
+
|
|
52
|
+
# Check custom BYOM registry first
|
|
53
|
+
custom_cls = AdapterRegistry.get_custom(name_lower)
|
|
54
|
+
if custom_cls is not None:
|
|
55
|
+
return custom_cls(model_name=model_name, **kwargs)
|
|
56
|
+
|
|
57
|
+
# Mock identifiers
|
|
58
|
+
if name_lower in ("mock", "mock-agent", "golden-target", "synthetic"):
|
|
59
|
+
return MockModelAdapter(model_name=model_name, **kwargs)
|
|
60
|
+
|
|
61
|
+
# OpenRouter routing (e.g. "nvidia/...", "deepseek/...", "openrouter:model", or provider="openrouter")
|
|
62
|
+
provider = kwargs.pop("provider", None)
|
|
63
|
+
if (
|
|
64
|
+
provider == "openrouter"
|
|
65
|
+
or name_lower.startswith("openrouter:")
|
|
66
|
+
or ("/" in model_name and not name_lower.startswith("ollama:"))
|
|
67
|
+
):
|
|
68
|
+
clean_name = model_name.split("openrouter:", 1)[-1]
|
|
69
|
+
return OpenRouterAdapter(model_name=clean_name, **kwargs)
|
|
70
|
+
|
|
71
|
+
# OpenAI family
|
|
72
|
+
if (
|
|
73
|
+
name_lower.startswith("gpt-")
|
|
74
|
+
or name_lower.startswith("gpt")
|
|
75
|
+
or name_lower.startswith("o1")
|
|
76
|
+
or name_lower.startswith("o3")
|
|
77
|
+
or name_lower.startswith("o4")
|
|
78
|
+
or name_lower.startswith("chatgpt-")
|
|
79
|
+
):
|
|
80
|
+
return OpenAIAdapter(model_name=model_name, **kwargs)
|
|
81
|
+
|
|
82
|
+
# Anthropic Claude family
|
|
83
|
+
if (
|
|
84
|
+
name_lower.startswith("claude-")
|
|
85
|
+
or name_lower.startswith("claude")
|
|
86
|
+
or name_lower.startswith("sonnet")
|
|
87
|
+
or name_lower.startswith("haiku")
|
|
88
|
+
or name_lower.startswith("opus")
|
|
89
|
+
):
|
|
90
|
+
return AnthropicAdapter(model_name=model_name, **kwargs)
|
|
91
|
+
|
|
92
|
+
# Google Gemini family
|
|
93
|
+
if name_lower.startswith("gemini-") or name_lower.startswith("gemini"):
|
|
94
|
+
return GeminiAdapter(model_name=model_name, **kwargs)
|
|
95
|
+
|
|
96
|
+
# Mistral AI family
|
|
97
|
+
if name_lower.startswith("mistral") or name_lower.startswith("codestral"):
|
|
98
|
+
return MistralAdapter(model_name=model_name, **kwargs)
|
|
99
|
+
|
|
100
|
+
# Ollama / Local Sovereign models
|
|
101
|
+
if (
|
|
102
|
+
name_lower.startswith("ollama:")
|
|
103
|
+
or name_lower.startswith("llama")
|
|
104
|
+
or name_lower.startswith("qwen")
|
|
105
|
+
):
|
|
106
|
+
clean_name = model_name.split(":", 1)[-1]
|
|
107
|
+
return OllamaAdapter(model_name=clean_name, **kwargs)
|
|
108
|
+
|
|
109
|
+
# Fallback to Mock if unrecognized, or raise if mock is not desired
|
|
110
|
+
return MockModelAdapter(model_name=model_name, **kwargs)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def load_adapter_from_file(file_path: str, **kwargs: Any) -> BaseModelAdapter:
|
|
114
|
+
"""Dynamically load and instantiate a BaseModelAdapter subclass from a Python file."""
|
|
115
|
+
import importlib.util
|
|
116
|
+
import inspect
|
|
117
|
+
from pathlib import Path
|
|
118
|
+
|
|
119
|
+
path = Path(file_path).resolve()
|
|
120
|
+
if not path.exists():
|
|
121
|
+
raise FileNotFoundError(f"Adapter file not found: {file_path}")
|
|
122
|
+
|
|
123
|
+
spec = importlib.util.spec_from_file_location("custom_adapter_module", path)
|
|
124
|
+
if spec is None or spec.loader is None:
|
|
125
|
+
raise ImportError(f"Cannot load module from: {file_path}")
|
|
126
|
+
|
|
127
|
+
module = importlib.util.module_from_spec(spec)
|
|
128
|
+
spec.loader.exec_module(module)
|
|
129
|
+
|
|
130
|
+
for attr_name in dir(module):
|
|
131
|
+
attr = getattr(module, attr_name)
|
|
132
|
+
if (
|
|
133
|
+
inspect.isclass(attr)
|
|
134
|
+
and issubclass(attr, BaseModelAdapter)
|
|
135
|
+
and attr is not BaseModelAdapter
|
|
136
|
+
):
|
|
137
|
+
model_name = kwargs.pop("model_name", "custom-byom-model")
|
|
138
|
+
return attr(model_name=model_name, **kwargs)
|
|
139
|
+
|
|
140
|
+
raise ValueError(f"No BaseModelAdapter subclass found in {file_path}")
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# Copyright (c) 2026 ARPA Hellenic Logical Systems
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""Google Gemini model adapter (Gemini 2.5 Pro, Gemini 2.5 Flash)."""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from skillware_bench.core.adapters.base import (
|
|
11
|
+
BaseModelAdapter,
|
|
12
|
+
ModelResponse,
|
|
13
|
+
TokenUsage,
|
|
14
|
+
ToolCallRequest,
|
|
15
|
+
execute_with_retry,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from skillware.core.loader import SkillLoader
|
|
20
|
+
except ImportError:
|
|
21
|
+
SkillLoader = None # type: ignore
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class GeminiAdapter(BaseModelAdapter):
|
|
25
|
+
"""Adapter for Google Gemini models using the modern google-genai SDK."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
model_name: str = "gemini-2.5-flash",
|
|
30
|
+
api_key: Optional[str] = None,
|
|
31
|
+
**kwargs: Any,
|
|
32
|
+
):
|
|
33
|
+
super().__init__(model_name=model_name, api_key=api_key, **kwargs)
|
|
34
|
+
self.api_key = (
|
|
35
|
+
api_key or os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
|
|
36
|
+
)
|
|
37
|
+
self._client = None
|
|
38
|
+
|
|
39
|
+
def _get_client(self):
|
|
40
|
+
if self._client is None:
|
|
41
|
+
try:
|
|
42
|
+
from google import genai
|
|
43
|
+
|
|
44
|
+
self._client = genai.Client(api_key=self.api_key)
|
|
45
|
+
except ImportError:
|
|
46
|
+
raise ImportError(
|
|
47
|
+
"google-genai package is required for GeminiAdapter. Run: pip install google-genai"
|
|
48
|
+
)
|
|
49
|
+
return self._client
|
|
50
|
+
|
|
51
|
+
def format_tools(self, tool_definitions: List[Dict[str, Any]]) -> List[Any]:
|
|
52
|
+
from google.genai import types
|
|
53
|
+
|
|
54
|
+
declarations = []
|
|
55
|
+
for tool in tool_definitions:
|
|
56
|
+
if isinstance(tool, types.Tool):
|
|
57
|
+
return [tool]
|
|
58
|
+
if "manifest" in tool and SkillLoader is not None:
|
|
59
|
+
gemini_tool = SkillLoader.to_gemini_tool(tool)
|
|
60
|
+
if isinstance(gemini_tool, types.Tool):
|
|
61
|
+
declarations.extend(gemini_tool.function_declarations or [])
|
|
62
|
+
else:
|
|
63
|
+
declarations.append(gemini_tool)
|
|
64
|
+
else:
|
|
65
|
+
name = tool.get("name", "unknown_tool")
|
|
66
|
+
desc = tool.get("description", "")
|
|
67
|
+
params = tool.get("parameters", {})
|
|
68
|
+
declarations.append(
|
|
69
|
+
{
|
|
70
|
+
"name": name,
|
|
71
|
+
"description": desc,
|
|
72
|
+
"parameters": params,
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
if declarations:
|
|
77
|
+
return [types.Tool(function_declarations=declarations)]
|
|
78
|
+
return []
|
|
79
|
+
|
|
80
|
+
def chat(
|
|
81
|
+
self,
|
|
82
|
+
messages: List[Dict[str, Any]],
|
|
83
|
+
tools: Optional[List[Dict[str, Any]]] = None,
|
|
84
|
+
system_prompt: Optional[str] = None,
|
|
85
|
+
**kwargs: Any,
|
|
86
|
+
) -> ModelResponse:
|
|
87
|
+
client = self._get_client()
|
|
88
|
+
from google.genai import types
|
|
89
|
+
|
|
90
|
+
# Build contents from message list
|
|
91
|
+
contents = []
|
|
92
|
+
for m in messages:
|
|
93
|
+
role = m.get("role", "user")
|
|
94
|
+
content = m.get("content", "")
|
|
95
|
+
if role == "system":
|
|
96
|
+
if not system_prompt:
|
|
97
|
+
system_prompt = content
|
|
98
|
+
continue
|
|
99
|
+
gemini_role = "model" if role == "assistant" else "user"
|
|
100
|
+
contents.append(
|
|
101
|
+
types.Content(
|
|
102
|
+
role=gemini_role,
|
|
103
|
+
parts=[types.Part.from_text(text=str(content))],
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
config_params: Dict[str, Any] = {}
|
|
108
|
+
if system_prompt:
|
|
109
|
+
config_params["system_instruction"] = system_prompt
|
|
110
|
+
if self.temperature is not None:
|
|
111
|
+
config_params["temperature"] = self.temperature
|
|
112
|
+
if self.max_tokens:
|
|
113
|
+
config_params["max_output_tokens"] = self.max_tokens
|
|
114
|
+
if tools:
|
|
115
|
+
config_params["tools"] = self.format_tools(tools)
|
|
116
|
+
|
|
117
|
+
config = types.GenerateContentConfig(**config_params) if config_params else None
|
|
118
|
+
|
|
119
|
+
t0 = time.perf_counter()
|
|
120
|
+
|
|
121
|
+
def _call_api():
|
|
122
|
+
return client.models.generate_content(
|
|
123
|
+
model=self.model_name,
|
|
124
|
+
contents=contents,
|
|
125
|
+
config=config,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
response = execute_with_retry(_call_api)
|
|
129
|
+
latency_ms = (time.perf_counter() - t0) * 1000.0
|
|
130
|
+
|
|
131
|
+
content = ""
|
|
132
|
+
tool_calls: List[ToolCallRequest] = []
|
|
133
|
+
|
|
134
|
+
if hasattr(response, "text") and response.text:
|
|
135
|
+
content = response.text
|
|
136
|
+
|
|
137
|
+
if hasattr(response, "function_calls") and response.function_calls:
|
|
138
|
+
for fc in response.function_calls:
|
|
139
|
+
args = fc.args if isinstance(fc.args, dict) else dict(fc.args or {})
|
|
140
|
+
tool_calls.append(
|
|
141
|
+
ToolCallRequest(
|
|
142
|
+
name=fc.name,
|
|
143
|
+
arguments=args,
|
|
144
|
+
id=fc.name,
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Extract tokens from usage_metadata
|
|
149
|
+
prompt_tokens = 0
|
|
150
|
+
completion_tokens = 0
|
|
151
|
+
cached_tokens = 0
|
|
152
|
+
total_tokens = 0
|
|
153
|
+
|
|
154
|
+
if hasattr(response, "usage_metadata") and response.usage_metadata:
|
|
155
|
+
meta = response.usage_metadata
|
|
156
|
+
prompt_tokens = getattr(meta, "prompt_token_count", 0) or 0
|
|
157
|
+
completion_tokens = getattr(meta, "candidates_token_count", 0) or 0
|
|
158
|
+
cached_tokens = getattr(meta, "cached_content_token_count", 0) or 0
|
|
159
|
+
total_tokens = getattr(meta, "total_token_count", 0) or (
|
|
160
|
+
prompt_tokens + completion_tokens
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
usage = TokenUsage(
|
|
164
|
+
prompt_tokens=prompt_tokens,
|
|
165
|
+
completion_tokens=completion_tokens,
|
|
166
|
+
cached_tokens=cached_tokens,
|
|
167
|
+
total_tokens=total_tokens,
|
|
168
|
+
)
|
|
169
|
+
self.record_usage(usage)
|
|
170
|
+
|
|
171
|
+
return ModelResponse(
|
|
172
|
+
content=content,
|
|
173
|
+
tool_calls=tool_calls,
|
|
174
|
+
usage=usage,
|
|
175
|
+
latency_ms=latency_ms,
|
|
176
|
+
ttft_ms=latency_ms * 0.35,
|
|
177
|
+
model=self.model_name,
|
|
178
|
+
raw=response,
|
|
179
|
+
)
|