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.
Files changed (49) hide show
  1. skillware_bench/__init__.py +62 -0
  2. skillware_bench/cli.py +639 -0
  3. skillware_bench/core/adapters/__init__.py +50 -0
  4. skillware_bench/core/adapters/anthropic_adapter.py +166 -0
  5. skillware_bench/core/adapters/base.py +182 -0
  6. skillware_bench/core/adapters/factory.py +140 -0
  7. skillware_bench/core/adapters/gemini_adapter.py +179 -0
  8. skillware_bench/core/adapters/mistral_adapter.py +136 -0
  9. skillware_bench/core/adapters/mock_adapter.py +100 -0
  10. skillware_bench/core/adapters/ollama_adapter.py +137 -0
  11. skillware_bench/core/adapters/openai_adapter.py +164 -0
  12. skillware_bench/core/adapters/openrouter_adapter.py +446 -0
  13. skillware_bench/core/adapters/registry.py +8 -0
  14. skillware_bench/core/agent_loop.py +383 -0
  15. skillware_bench/core/evaluators.py +246 -0
  16. skillware_bench/core/models.py +96 -0
  17. skillware_bench/core/openrouter_engine.py +357 -0
  18. skillware_bench/core/perturbations.py +130 -0
  19. skillware_bench/core/provenance.py +371 -0
  20. skillware_bench/core/runner.py +147 -0
  21. skillware_bench/core/statistics.py +123 -0
  22. skillware_bench/dashboard/__init__.py +8 -0
  23. skillware_bench/dashboard/generator.py +248 -0
  24. skillware_bench/fixtures/__init__.py +9 -0
  25. skillware_bench/fixtures/mock_companies_registry.py +419 -0
  26. skillware_bench/fixtures/mock_inbox_registry.py +228 -0
  27. skillware_bench/infra/__init__.py +27 -0
  28. skillware_bench/infra/companies_house_live.py +228 -0
  29. skillware_bench/infra/disposable_mailbox.py +210 -0
  30. skillware_bench/infra/evm_testnet_fork.py +179 -0
  31. skillware_bench/infra/hermetic_mock_server.py +354 -0
  32. skillware_bench/infra/wallet_screening_live.py +143 -0
  33. skillware_bench/suites/__init__.py +33 -0
  34. skillware_bench/suites/chained_pipeline_crucible.py +177 -0
  35. skillware_bench/suites/companies_house_crucible.py +187 -0
  36. skillware_bench/suites/constitution_crucible.py +171 -0
  37. skillware_bench/suites/context_drift_crucible.py +254 -0
  38. skillware_bench/suites/distraction_registry.py +249 -0
  39. skillware_bench/suites/gmail_crucible.py +207 -0
  40. skillware_bench/suites/prompt_injection_crucible.py +263 -0
  41. skillware_bench/suites/wallet_screening_crucible.py +167 -0
  42. skillware_bench/viz/__init__.py +8 -0
  43. skillware_bench/viz/generator.py +179 -0
  44. skillware_bench-0.3.2.dist-info/METADATA +287 -0
  45. skillware_bench-0.3.2.dist-info/RECORD +49 -0
  46. skillware_bench-0.3.2.dist-info/WHEEL +5 -0
  47. skillware_bench-0.3.2.dist-info/entry_points.txt +2 -0
  48. skillware_bench-0.3.2.dist-info/licenses/LICENSE +21 -0
  49. 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
+ )