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,446 @@
1
+ # Copyright (c) 2026 ARPA Hellenic Logical Systems
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """OpenRouter model adapter for frontier and sovereign models via OpenRouter API."""
5
+
6
+ import copy
7
+ import json
8
+ import os
9
+ import time
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ from skillware_bench.core.adapters.base import (
13
+ BaseModelAdapter,
14
+ ModelResponse,
15
+ TokenUsage,
16
+ ToolCallRequest,
17
+ calculate_cost,
18
+ execute_with_retry,
19
+ )
20
+
21
+ try:
22
+ from skillware.core.loader import SkillLoader
23
+ except ImportError:
24
+ SkillLoader = None # type: ignore
25
+
26
+
27
+ class OpenRouterAdapter(BaseModelAdapter):
28
+ """Adapter for models hosted via OpenRouter (frontier, sovereign, and batch)."""
29
+
30
+ OPENROUTER_DEFAULT_URL = "https://openrouter.ai/api/v1"
31
+
32
+ def __init__(
33
+ self,
34
+ model_name: str,
35
+ api_key: Optional[str] = None,
36
+ base_url: Optional[str] = None,
37
+ site_url: str = "https://github.com/ARPAHLS/skillware-bench",
38
+ app_name: str = "Skillware-Bench",
39
+ **kwargs: Any,
40
+ ):
41
+ super().__init__(model_name=model_name, api_key=api_key, **kwargs)
42
+ try:
43
+ from dotenv import load_dotenv
44
+
45
+ load_dotenv()
46
+ except Exception:
47
+ pass
48
+ self.api_key = (
49
+ api_key or os.environ.get("OPENROUTER_API_KEY") or os.environ.get("OPENROUTER_API")
50
+ )
51
+ self.base_url = (
52
+ base_url or os.environ.get("OPENROUTER_BASE_URL") or self.OPENROUTER_DEFAULT_URL
53
+ )
54
+ self.site_url = site_url
55
+ self.app_name = app_name
56
+ self._client = None
57
+
58
+ if self.max_tokens is None:
59
+ # Default to 1024 tokens to provide adequate room for reasoning models
60
+ self.max_tokens = 1024
61
+
62
+ def _get_client(self):
63
+ if self._client is None:
64
+ if not self.api_key:
65
+ raise ValueError(
66
+ "OpenRouter API key not configured. Set OPENROUTER_API or OPENROUTER_API_KEY environment variable."
67
+ )
68
+ try:
69
+ import openai
70
+
71
+ headers = {
72
+ "HTTP-Referer": self.site_url,
73
+ "X-Title": self.app_name,
74
+ }
75
+ self._client = openai.OpenAI(
76
+ api_key=self.api_key,
77
+ base_url=self.base_url,
78
+ default_headers=headers,
79
+ timeout=self.timeout_seconds,
80
+ )
81
+ except ImportError:
82
+ raise ImportError(
83
+ "openai package is required to use OpenRouterAdapter. Run: pip install openai"
84
+ )
85
+ return self._client
86
+
87
+ @staticmethod
88
+ def _sanitize_tool_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
89
+ """Sanitize JSON schema to ensure compatibility across all OpenRouter providers.
90
+
91
+ Deeply nested object schemas inside array items (such as pipelines or attachment lists)
92
+ cause BNF grammar blowouts or schema parsing errors in upstream engines (e.g. vLLM/Llama).
93
+ Simplifying array item schemas to generic objects preserves semantic intent while ensuring
94
+ robust execution across all models.
95
+ """
96
+ if not isinstance(schema, dict):
97
+ return schema
98
+ cleaned = copy.deepcopy(schema)
99
+
100
+ def _clean_node(node: Any):
101
+ if isinstance(node, dict):
102
+ if node.get("type") == "array" and isinstance(node.get("items"), dict):
103
+ items = node["items"]
104
+ if items.get("type") == "object" and "properties" in items:
105
+ node["items"] = {"type": "object"}
106
+ for v in node.values():
107
+ _clean_node(v)
108
+ elif isinstance(node, list):
109
+ for item in node:
110
+ _clean_node(item)
111
+
112
+ _clean_node(cleaned)
113
+ return cleaned
114
+
115
+ def format_tools(self, tool_definitions: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
116
+ """Format tools into standard OpenAI function calling specifications."""
117
+ formatted: List[Dict[str, Any]] = []
118
+ for tool in tool_definitions:
119
+ tool_entry = None
120
+ if (
121
+ isinstance(tool, dict)
122
+ and "type" in tool
123
+ and tool["type"] == "function"
124
+ and "function" in tool
125
+ ):
126
+ tool_entry = copy.deepcopy(tool)
127
+ elif isinstance(tool, dict) and "manifest" in tool and SkillLoader is not None:
128
+ tool_entry = copy.deepcopy(SkillLoader.to_openai_tool(tool))
129
+ elif isinstance(tool, dict):
130
+ name = tool.get("name", "unknown_tool")
131
+ desc = tool.get("description", "")
132
+ params = tool.get("parameters", {"type": "object", "properties": {}})
133
+ tool_entry = {
134
+ "type": "function",
135
+ "function": {
136
+ "name": name,
137
+ "description": desc,
138
+ "parameters": params,
139
+ },
140
+ }
141
+
142
+ if tool_entry and "function" in tool_entry and "parameters" in tool_entry["function"]:
143
+ tool_entry["function"]["parameters"] = self._sanitize_tool_schema(
144
+ tool_entry["function"]["parameters"]
145
+ )
146
+ if tool_entry:
147
+ formatted.append(tool_entry)
148
+
149
+ return formatted
150
+
151
+ def _sanitize_messages(
152
+ self,
153
+ messages: List[Dict[str, Any]],
154
+ system_prompt: Optional[str] = None,
155
+ has_tools: bool = False,
156
+ ) -> List[Dict[str, Any]]:
157
+ """Normalize message history into strict OpenAI/OpenRouter wire format."""
158
+ sanitized: List[Dict[str, Any]] = []
159
+
160
+ # Upstream Jinja chat templates for Llama models inject tool definitions into the system prompt.
161
+ # Having an explicit separate system message causes Jinja template collision / instant EOS.
162
+ # Prepending system instructions into the first user message preserves instructions while enabling tools.
163
+ is_llama = any(k in self.model_name.lower() for k in ["llama", "maverick", "scout"])
164
+ merge_system = has_tools and is_llama
165
+
166
+ pending_system_text = ""
167
+ if system_prompt:
168
+ pending_system_text = system_prompt.strip()
169
+
170
+ if merge_system:
171
+ system_pieces = [pending_system_text] if pending_system_text else []
172
+ other_messages = []
173
+ for m in messages:
174
+ if m.get("role") == "system":
175
+ content = (m.get("content") or "").strip()
176
+ if content:
177
+ system_pieces.append(content)
178
+ else:
179
+ other_messages.append(m)
180
+
181
+ combined_system = "\n\n".join(system_pieces).strip()
182
+ first_user_found = False
183
+
184
+ for m in other_messages:
185
+ role = m.get("role", "user")
186
+ content = m.get("content")
187
+
188
+ if role == "user" and not first_user_found:
189
+ first_user_found = True
190
+ user_content = str(content or "")
191
+ if combined_system:
192
+ user_content = f"{combined_system}\n\n{user_content}"
193
+ sanitized.append({"role": "user", "content": user_content})
194
+ elif role == "assistant" and "tool_calls" in m and m["tool_calls"]:
195
+ tc_list = []
196
+ for tc in m["tool_calls"]:
197
+ tc_id = tc.get("id") or "call_default"
198
+ fn_name = (
199
+ tc.get("name")
200
+ or (tc.get("function") or {}).get("name")
201
+ or "unknown_tool"
202
+ )
203
+ args = (
204
+ tc.get("arguments") or (tc.get("function") or {}).get("arguments") or {}
205
+ )
206
+ args_str = json.dumps(args) if isinstance(args, dict) else str(args)
207
+ tc_list.append(
208
+ {
209
+ "id": tc_id,
210
+ "type": "function",
211
+ "function": {
212
+ "name": fn_name,
213
+ "arguments": args_str,
214
+ },
215
+ }
216
+ )
217
+ sanitized.append(
218
+ {
219
+ "role": "assistant",
220
+ "content": content or "",
221
+ "tool_calls": tc_list,
222
+ }
223
+ )
224
+ elif role == "tool":
225
+ sanitized.append(
226
+ {
227
+ "role": "tool",
228
+ "tool_call_id": m.get("tool_call_id") or m.get("id", "call_default"),
229
+ "name": m.get("name", ""),
230
+ "content": str(content or ""),
231
+ }
232
+ )
233
+ else:
234
+ sanitized.append(
235
+ {
236
+ "role": role,
237
+ "content": str(content or ""),
238
+ }
239
+ )
240
+
241
+ if not first_user_found and combined_system:
242
+ sanitized.insert(0, {"role": "user", "content": combined_system})
243
+
244
+ return sanitized
245
+
246
+ if system_prompt and not any(m.get("role") == "system" for m in messages):
247
+ sanitized.append({"role": "system", "content": system_prompt})
248
+
249
+ for m in messages:
250
+ role = m.get("role", "user")
251
+ content = m.get("content")
252
+
253
+ if role == "assistant" and "tool_calls" in m and m["tool_calls"]:
254
+ tc_list = []
255
+ for tc in m["tool_calls"]:
256
+ tc_id = tc.get("id") or "call_default"
257
+ fn_name = (
258
+ tc.get("name") or (tc.get("function") or {}).get("name") or "unknown_tool"
259
+ )
260
+ args = tc.get("arguments") or (tc.get("function") or {}).get("arguments") or {}
261
+ args_str = json.dumps(args) if isinstance(args, dict) else str(args)
262
+ tc_list.append(
263
+ {
264
+ "id": tc_id,
265
+ "type": "function",
266
+ "function": {
267
+ "name": fn_name,
268
+ "arguments": args_str,
269
+ },
270
+ }
271
+ )
272
+ sanitized.append(
273
+ {
274
+ "role": "assistant",
275
+ "content": content or "",
276
+ "tool_calls": tc_list,
277
+ }
278
+ )
279
+ elif role == "tool":
280
+ sanitized.append(
281
+ {
282
+ "role": "tool",
283
+ "tool_call_id": m.get("tool_call_id") or m.get("id", "call_default"),
284
+ "name": m.get("name", ""),
285
+ "content": str(content or ""),
286
+ }
287
+ )
288
+ else:
289
+ sanitized.append(
290
+ {
291
+ "role": role,
292
+ "content": str(content or ""),
293
+ }
294
+ )
295
+
296
+ return sanitized
297
+
298
+ def chat(
299
+ self,
300
+ messages: List[Dict[str, Any]],
301
+ tools: Optional[List[Dict[str, Any]]] = None,
302
+ system_prompt: Optional[str] = None,
303
+ **kwargs: Any,
304
+ ) -> ModelResponse:
305
+ """Send chat messages and optional tool definitions to OpenRouter."""
306
+ client = self._get_client()
307
+ sanitized_messages = self._sanitize_messages(
308
+ messages, system_prompt=system_prompt, has_tools=bool(tools)
309
+ )
310
+
311
+ payload: Dict[str, Any] = {
312
+ "model": self.model_name,
313
+ "messages": sanitized_messages,
314
+ }
315
+
316
+ if self.temperature is not None and not (
317
+ ":free" in self.model_name and "reasoning" in self.model_name
318
+ ):
319
+ payload["temperature"] = self.temperature
320
+
321
+ if self.max_tokens:
322
+ payload["max_tokens"] = self.max_tokens
323
+
324
+ if tools:
325
+ payload["tools"] = self.format_tools(tools)
326
+ payload["tool_choice"] = kwargs.get("tool_choice", "auto")
327
+
328
+ # Exclude upstream backend providers that reject parallel tool calls
329
+ if "extra_body" in kwargs:
330
+ payload["extra_body"] = kwargs["extra_body"]
331
+ elif "provider" in self.extra_config:
332
+ payload["extra_body"] = {"provider": self.extra_config["provider"]}
333
+ else:
334
+ payload["extra_body"] = {"provider": {"ignore": ["CoreWeave", "Groq"]}}
335
+
336
+ t0 = time.perf_counter()
337
+
338
+ def _call_api():
339
+ res = client.chat.completions.create(**payload)
340
+ if not getattr(res, "choices", None):
341
+ err = (
342
+ getattr(res, "error", None)
343
+ or "Empty response choices from OpenRouter upstream provider"
344
+ )
345
+ raise RuntimeError(f"OpenRouter provider error: {err}")
346
+ return res
347
+
348
+ try:
349
+ import openai
350
+
351
+ retry_types = (
352
+ openai.RateLimitError,
353
+ openai.APIConnectionError,
354
+ openai.InternalServerError,
355
+ openai.APITimeoutError,
356
+ RuntimeError,
357
+ )
358
+ except Exception:
359
+ retry_types = (Exception,)
360
+
361
+ response = execute_with_retry(
362
+ _call_api,
363
+ max_retries=5,
364
+ initial_delay=2.0,
365
+ backoff_factor=2.0,
366
+ jitter=True,
367
+ retryable_exceptions=retry_types,
368
+ )
369
+ latency_ms = (time.perf_counter() - t0) * 1000.0
370
+
371
+ choice = response.choices[0]
372
+ msg = getattr(choice, "message", None)
373
+ content = (getattr(msg, "content", "") or "") if msg else ""
374
+ refusal = (getattr(msg, "refusal", "") or "") if msg else ""
375
+ if not content and refusal:
376
+ content = refusal
377
+
378
+ # Fallback to reasoning text if content is empty and no tool calls exist
379
+ reasoning_text = (getattr(msg, "reasoning", "") or "") if msg else ""
380
+ tool_calls: List[ToolCallRequest] = []
381
+
382
+ if msg and getattr(msg, "tool_calls", None):
383
+ for tc in msg.tool_calls:
384
+ fn_name = tc.function.name
385
+ try:
386
+ args = json.loads(tc.function.arguments) if tc.function.arguments else {}
387
+ except json.JSONDecodeError:
388
+ args = {"_raw": tc.function.arguments}
389
+ tool_calls.append(ToolCallRequest(name=fn_name, arguments=args, id=tc.id))
390
+
391
+ if not content and not tool_calls and reasoning_text:
392
+ content = reasoning_text.strip()
393
+
394
+ # Telemetry & exact cost tracking
395
+ prompt_tokens = getattr(response.usage, "prompt_tokens", 0) or 0
396
+ completion_tokens = getattr(response.usage, "completion_tokens", 0) or 0
397
+ total_tokens = getattr(response.usage, "total_tokens", 0) or (
398
+ prompt_tokens + completion_tokens
399
+ )
400
+
401
+ cached_tokens = 0
402
+ if (
403
+ hasattr(response.usage, "prompt_tokens_details")
404
+ and response.usage.prompt_tokens_details
405
+ ):
406
+ cached_tokens = getattr(response.usage.prompt_tokens_details, "cached_tokens", 0) or 0
407
+
408
+ # Exact upstream cost from OpenRouter if provided
409
+ direct_cost = getattr(response.usage, "cost", None)
410
+ if direct_cost is not None and isinstance(direct_cost, (int, float)):
411
+ cost_usd = float(direct_cost)
412
+ elif ":free" in self.model_name.lower():
413
+ cost_usd = 0.0
414
+ else:
415
+ usage_temp = TokenUsage(
416
+ prompt_tokens=prompt_tokens,
417
+ completion_tokens=completion_tokens,
418
+ cached_tokens=cached_tokens,
419
+ total_tokens=total_tokens,
420
+ )
421
+ cost_usd = calculate_cost(self.model_name, usage_temp)
422
+
423
+ usage = TokenUsage(
424
+ prompt_tokens=prompt_tokens,
425
+ completion_tokens=completion_tokens,
426
+ cached_tokens=cached_tokens,
427
+ total_tokens=total_tokens,
428
+ cost_usd=cost_usd,
429
+ )
430
+
431
+ # Accumulate usage on adapter
432
+ self.cumulative_usage.prompt_tokens += usage.prompt_tokens
433
+ self.cumulative_usage.completion_tokens += usage.completion_tokens
434
+ self.cumulative_usage.cached_tokens += usage.cached_tokens
435
+ self.cumulative_usage.total_tokens += usage.total_tokens
436
+ self.cumulative_cost_usd += usage.cost_usd
437
+
438
+ return ModelResponse(
439
+ content=content,
440
+ tool_calls=tool_calls,
441
+ usage=usage,
442
+ latency_ms=latency_ms,
443
+ ttft_ms=latency_ms * 0.45,
444
+ model=self.model_name,
445
+ raw=response,
446
+ )
@@ -0,0 +1,8 @@
1
+ # Copyright (c) 2026 ARPA Hellenic Logical Systems
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """BYOM model adapter registry re-export module."""
5
+
6
+ from skillware_bench.core.adapters.factory import AdapterRegistry
7
+
8
+ __all__ = ["AdapterRegistry"]