tooluniverse 1.0.4__py3-none-any.whl → 1.0.5__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.

Potentially problematic release.


This version of tooluniverse might be problematic. Click here for more details.

@@ -367,3 +367,204 @@ class GeminiClient(BaseLLMClient):
367
367
  retries += 1
368
368
  time.sleep(retry_delay * retries)
369
369
  return None
370
+
371
+
372
+ class OpenRouterClient(BaseLLMClient):
373
+ """
374
+ OpenRouter client using OpenAI SDK with custom base URL.
375
+ Supports models from OpenAI, Anthropic, Google, Qwen, and many other providers.
376
+ """
377
+
378
+ # Default model limits based on latest OpenRouter offerings
379
+ DEFAULT_MODEL_LIMITS: Dict[str, Dict[str, int]] = {
380
+ "openai/gpt-5": {"max_output": 128_000, "context_window": 400_000},
381
+ "openai/gpt-5-codex": {"max_output": 128_000, "context_window": 400_000},
382
+ "google/gemini-2.5-flash": {"max_output": 65_536, "context_window": 1_000_000},
383
+ "google/gemini-2.5-pro": {"max_output": 65_536, "context_window": 1_000_000},
384
+ "anthropic/claude-sonnet-4.5": {"max_output": 16_384, "context_window": 1_000_000},
385
+ }
386
+
387
+ def __init__(self, model_id: str, logger):
388
+ try:
389
+ from openai import OpenAI as _OpenAI # type: ignore
390
+ import openai as _openai # type: ignore
391
+ except Exception as e: # pragma: no cover
392
+ raise RuntimeError("openai client is not available") from e
393
+
394
+ self._OpenAI = _OpenAI
395
+ self._openai = _openai
396
+ self.model_name = model_id
397
+ self.logger = logger
398
+
399
+ api_key = os.getenv("OPENROUTER_API_KEY")
400
+ if not api_key:
401
+ raise ValueError("OPENROUTER_API_KEY not set")
402
+
403
+ # Optional headers for OpenRouter
404
+ default_headers = {}
405
+ if site_url := os.getenv("OPENROUTER_SITE_URL"):
406
+ default_headers["HTTP-Referer"] = site_url
407
+ if site_name := os.getenv("OPENROUTER_SITE_NAME"):
408
+ default_headers["X-Title"] = site_name
409
+
410
+ self.client = self._OpenAI(
411
+ base_url="https://openrouter.ai/api/v1",
412
+ api_key=api_key,
413
+ default_headers=default_headers if default_headers else None,
414
+ )
415
+
416
+ # Load env overrides for model limits
417
+ env_limits_raw = os.getenv("OPENROUTER_DEFAULT_MODEL_LIMITS")
418
+ self._default_limits: Dict[str, Dict[str, int]] = (
419
+ self.DEFAULT_MODEL_LIMITS.copy()
420
+ )
421
+ if env_limits_raw:
422
+ try:
423
+ env_limits = _json.loads(env_limits_raw)
424
+ for k, v in env_limits.items():
425
+ if isinstance(v, dict):
426
+ base = self._default_limits.get(k, {}).copy()
427
+ base.update(
428
+ {
429
+ kk: int(vv)
430
+ for kk, vv in v.items()
431
+ if isinstance(vv, (int, float, str))
432
+ }
433
+ )
434
+ self._default_limits[k] = base
435
+ except Exception:
436
+ pass
437
+
438
+ def _resolve_default_max_tokens(self, model_id: str) -> Optional[int]:
439
+ """Resolve default max tokens for a model."""
440
+ # Highest priority: explicit env per-model tokens mapping
441
+ mapping_raw = os.getenv("OPENROUTER_MAX_TOKENS_BY_MODEL")
442
+ mapping: Dict[str, Any] = {}
443
+ if mapping_raw:
444
+ try:
445
+ mapping = _json.loads(mapping_raw)
446
+ except Exception:
447
+ mapping = {}
448
+
449
+ if model_id in mapping:
450
+ try:
451
+ return int(mapping[model_id])
452
+ except Exception:
453
+ pass
454
+
455
+ # Check for prefix match
456
+ for k, v in mapping.items():
457
+ try:
458
+ if model_id.startswith(k):
459
+ return int(v)
460
+ except Exception:
461
+ continue
462
+
463
+ # Next: built-in/default-limits map
464
+ if model_id in self._default_limits:
465
+ return int(self._default_limits[model_id].get("max_output", 0)) or None
466
+
467
+ # Check for prefix match in default limits
468
+ for k, v in self._default_limits.items():
469
+ try:
470
+ if model_id.startswith(k):
471
+ return int(v.get("max_output", 0)) or None
472
+ except Exception:
473
+ continue
474
+
475
+ return None
476
+
477
+ def test_api(self) -> None:
478
+ """Test API connectivity with minimal token usage."""
479
+ test_messages = [{"role": "user", "content": "ping"}]
480
+ token_attempts = [1, 4, 16, 32]
481
+ last_error: Optional[Exception] = None
482
+
483
+ for tok in token_attempts:
484
+ try:
485
+ self.client.chat.completions.create(
486
+ model=self.model_name,
487
+ messages=test_messages,
488
+ max_tokens=tok,
489
+ temperature=0,
490
+ )
491
+ return
492
+ except Exception as e: # noqa: BLE001
493
+ last_error = e
494
+ msg = str(e).lower()
495
+ if (
496
+ "max_tokens" in msg
497
+ or "model output limit" in msg
498
+ or "finish the message" in msg
499
+ ) and tok != token_attempts[-1]:
500
+ continue
501
+ break
502
+
503
+ if last_error:
504
+ raise ValueError(f"OpenRouter API test failed: {last_error}")
505
+ raise ValueError("OpenRouter API test failed: unknown error")
506
+
507
+ def infer(
508
+ self,
509
+ messages: List[Dict[str, str]],
510
+ temperature: Optional[float],
511
+ max_tokens: Optional[int],
512
+ return_json: bool,
513
+ custom_format: Any = None,
514
+ max_retries: int = 5,
515
+ retry_delay: int = 5,
516
+ ) -> Optional[str]:
517
+ """Execute inference using OpenRouter."""
518
+ retries = 0
519
+ call_fn = (
520
+ self.client.chat.completions.parse
521
+ if custom_format is not None
522
+ else self.client.chat.completions.create
523
+ )
524
+
525
+ response_format = (
526
+ custom_format
527
+ if custom_format is not None
528
+ else ({"type": "json_object"} if return_json else None)
529
+ )
530
+
531
+ eff_max = (
532
+ max_tokens
533
+ if max_tokens is not None
534
+ else self._resolve_default_max_tokens(self.model_name)
535
+ )
536
+
537
+ while retries < max_retries:
538
+ try:
539
+ kwargs: Dict[str, Any] = {
540
+ "model": self.model_name,
541
+ "messages": messages,
542
+ }
543
+
544
+ if response_format is not None:
545
+ kwargs["response_format"] = response_format
546
+ if temperature is not None:
547
+ kwargs["temperature"] = temperature
548
+ if eff_max is not None:
549
+ kwargs["max_tokens"] = eff_max
550
+
551
+ resp = call_fn(**kwargs)
552
+
553
+ if custom_format is not None:
554
+ return resp.choices[0].message.parsed.model_dump()
555
+ return resp.choices[0].message.content
556
+
557
+ except self._openai.RateLimitError: # type: ignore[attr-defined]
558
+ self.logger.warning(
559
+ f"Rate limit exceeded. Retrying in {retry_delay} seconds..."
560
+ )
561
+ retries += 1
562
+ time.sleep(retry_delay * retries)
563
+ except Exception as e: # noqa: BLE001
564
+ self.logger.error(f"OpenRouter error: {e}")
565
+ import traceback
566
+ traceback.print_exc()
567
+ break
568
+
569
+ self.logger.error("Max retries exceeded. Unable to complete the request.")
570
+ return None
@@ -327,13 +327,12 @@ def _start_server_for_port(port: int, **kwargs):
327
327
 
328
328
  print(f"🚀 Starting MCP server on port {port} with {len(tools)} tools...")
329
329
 
330
- # Create SMCP server with stateless mode for compatibility
330
+ # Create SMCP server for compatibility
331
331
  server = _get_smcp()(
332
332
  name=config["server_name"],
333
333
  auto_expose_tools=False, # We'll add tools manually
334
334
  search_enabled=True,
335
335
  max_workers=config.get("max_workers", 5),
336
- stateless_http=True, # Enable stateless mode for MCPAutoLoaderTool compatibility
337
336
  **kwargs,
338
337
  )
339
338
 
@@ -347,8 +346,9 @@ def _start_server_for_port(port: int, **kwargs):
347
346
  # Start server in background thread
348
347
  def run_server():
349
348
  try:
349
+ # Enable stateless mode for MCPAutoLoaderTool compatibility
350
350
  server.run_simple(
351
- transport=config["transport"], host=config["host"], port=port
351
+ transport=config["transport"], host=config["host"], port=port, stateless_http=True
352
352
  )
353
353
  except Exception as e:
354
354
  print(f"❌ Error running MCP server on port {port}: {e}")
@@ -0,0 +1,226 @@
1
+ import re
2
+ import requests
3
+ from typing import Dict, Any, Optional, List
4
+ from .base_tool import BaseTool
5
+ from .tool_registry import register_tool
6
+
7
+ # Optional but recommended: text extraction for HTML
8
+ try:
9
+ from bs4 import BeautifulSoup # pip install beautifulsoup4
10
+ except ImportError:
11
+ BeautifulSoup = None # We’ll guard uses so the tool still loads
12
+
13
+ ODPHP_BASE_URL = "https://odphp.health.gov/myhealthfinder/api/v4"
14
+
15
+
16
+ class ODPHPRESTTool(BaseTool):
17
+ """Base class for ODPHP (MyHealthfinder) REST API tools."""
18
+
19
+ def __init__(self, tool_config):
20
+ super().__init__(tool_config)
21
+ self.endpoint = tool_config["fields"]["endpoint"]
22
+
23
+ def _make_request(self, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
24
+ url = f"{ODPHP_BASE_URL}{self.endpoint}"
25
+ try:
26
+ resp = requests.get(url, params=params, timeout=30)
27
+ resp.raise_for_status()
28
+ data = resp.json()
29
+ return {
30
+ "data": data.get("Result"),
31
+ "metadata": {
32
+ "source": "ODPHP MyHealthfinder",
33
+ "endpoint": url,
34
+ "query": params,
35
+ },
36
+ }
37
+ except requests.exceptions.RequestException as e:
38
+ return {"error": f"Request failed: {str(e)}"}
39
+ except ValueError as e:
40
+ return {"error": f"Failed to parse JSON: {str(e)}"}
41
+
42
+
43
+ def _sections_array(resource: Dict[str, Any]) -> List[Dict[str, Any]]:
44
+ """
45
+ Tolerant accessor for the sections array.
46
+ Data sometimes uses Sections.Section (capital S) and sometimes Sections.section (lowercase).
47
+ """
48
+ sect = resource.get("Sections") or {}
49
+ arr = sect.get("Section")
50
+ if not isinstance(arr, list):
51
+ arr = sect.get("section")
52
+ return arr if isinstance(arr, list) else []
53
+
54
+
55
+ def _strip_html_to_text(html: str) -> str:
56
+ if not html:
57
+ return ""
58
+ if BeautifulSoup is None:
59
+ # fallback: very light tag remover
60
+ text = re.sub(r"<[^>]+>", " ", html)
61
+ return re.sub(r"\s+", " ", text).strip()
62
+ soup = BeautifulSoup(html, "html.parser")
63
+ # remove scripts/styles
64
+ for t in soup(["script", "style", "noscript"]):
65
+ t.decompose()
66
+ text = soup.get_text("\n", strip=True)
67
+ text = re.sub(r"\n{2,}", "\n\n", text)
68
+ return text.strip()
69
+
70
+
71
+ @register_tool("ODPHPMyHealthfinder")
72
+ class ODPHPMyHealthfinder(ODPHPRESTTool):
73
+ """Search for demographic-specific health recommendations (MyHealthfinder)."""
74
+
75
+ def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
76
+ params: Dict[str, Any] = {}
77
+ if "lang" in arguments:
78
+ params["lang"] = arguments["lang"]
79
+ if "age" in arguments:
80
+ params["age"] = arguments["age"]
81
+ if "sex" in arguments:
82
+ params["sex"] = arguments["sex"]
83
+ if "pregnant" in arguments:
84
+ params["pregnant"] = arguments["pregnant"]
85
+
86
+ res = self._make_request(params)
87
+
88
+ # Optional: attach PlainSections if requested
89
+ if isinstance(res, dict) and not res.get("error") and arguments.get("strip_html"):
90
+ data = res.get("data") or {}
91
+ resources = (((data.get("Resources") or {}).get("All") or {}).get("Resource")) or []
92
+ if isinstance(resources, list):
93
+ for r in resources:
94
+ plain = []
95
+ for sec in _sections_array(r):
96
+ plain.append({
97
+ "Title": sec.get("Title", ""),
98
+ "PlainContent": _strip_html_to_text(sec.get("Content", "")),
99
+ })
100
+ if plain:
101
+ r["PlainSections"] = plain
102
+ return res
103
+
104
+
105
+ @register_tool("ODPHPItemList")
106
+ class ODPHPItemList(ODPHPRESTTool):
107
+ """Retrieve list of topics or categories."""
108
+
109
+ def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
110
+ params: Dict[str, Any] = {}
111
+ if "lang" in arguments:
112
+ params["lang"] = arguments["lang"]
113
+ if "type" in arguments:
114
+ params["type"] = arguments["type"]
115
+ return self._make_request(params)
116
+
117
+
118
+ @register_tool("ODPHPTopicSearch")
119
+ class ODPHPTopicSearch(ODPHPRESTTool):
120
+ """Search for health topics by ID, category, or keyword."""
121
+
122
+ def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
123
+ params: Dict[str, Any] = {}
124
+ if "lang" in arguments:
125
+ params["lang"] = arguments["lang"]
126
+ if "topicId" in arguments:
127
+ params["topicId"] = arguments["topicId"]
128
+ if "categoryId" in arguments:
129
+ params["categoryId"] = arguments["categoryId"]
130
+ if "keyword" in arguments:
131
+ params["keyword"] = arguments["keyword"]
132
+
133
+ res = self._make_request(params)
134
+
135
+ # Optional: attach PlainSections if requested
136
+ if isinstance(res, dict) and not res.get("error") and arguments.get("strip_html"):
137
+ data = res.get("data") or {}
138
+ resources = ((data.get("Resources") or {}).get("Resource")) or []
139
+ if isinstance(resources, list):
140
+ for r in resources:
141
+ plain = []
142
+ for sec in _sections_array(r):
143
+ plain.append({
144
+ "Title": sec.get("Title", ""),
145
+ "PlainContent": _strip_html_to_text(sec.get("Content", "")),
146
+ })
147
+ if plain:
148
+ r["PlainSections"] = plain
149
+ return res
150
+
151
+
152
+ @register_tool("ODPHPOutlinkFetch")
153
+ class ODPHPOutlinkFetch(BaseTool):
154
+ """
155
+ Fetch article pages referenced by AccessibleVersion / RelatedItems.Url and return readable text.
156
+ - HTML: extracts main/article/body text; strips nav/aside/footer/script/style.
157
+ - PDF or non-HTML: returns metadata + URL so the agent can surface it.
158
+ """
159
+
160
+ def __init__(self, tool_config):
161
+ super().__init__(tool_config)
162
+ self.timeout = 30
163
+
164
+ def _extract_text(self, html: str) -> Dict[str, str]:
165
+ if BeautifulSoup is None:
166
+ # fallback: crude extraction
167
+ title = ""
168
+ # attempt to find <title>
169
+ m = re.search(r"<title[^>]*>(.*?)</title>", html, flags=re.I | re.S)
170
+ if m:
171
+ title = re.sub(r"\s+", " ", m.group(1)).strip()
172
+ text = re.sub(r"<[^>]+>", " ", html)
173
+ text = re.sub(r"\s+", " ", text).strip()
174
+ return {"title": title, "text": text}
175
+
176
+ soup = BeautifulSoup(html, "html.parser")
177
+ # remove non-content
178
+ for tag in soup(["script", "style", "noscript", "footer", "nav", "aside"]):
179
+ tag.decompose()
180
+
181
+ candidate = soup.find("main") or soup.find("article") or soup.body or soup
182
+ title = ""
183
+ # prefer main/article heading, else <title>
184
+ h = candidate.find(["h1", "h2"]) if candidate else None
185
+ if h:
186
+ title = h.get_text(" ", strip=True)
187
+ elif soup.title and soup.title.string:
188
+ title = soup.title.string.strip()
189
+
190
+ text = candidate.get_text("\n", strip=True) if candidate else soup.get_text("\n", strip=True)
191
+ text = re.sub(r"\n{2,}", "\n\n", text)
192
+ return {"title": title, "text": text}
193
+
194
+ def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
195
+ urls: List[str] = arguments.get("urls", [])
196
+ max_chars: Optional[int] = arguments.get("max_chars")
197
+ return_html: bool = bool(arguments.get("return_html", False))
198
+
199
+ if not urls or not isinstance(urls, list):
200
+ return {"error": "Missing required parameter 'urls' (array of 1–3 URLs)."}
201
+
202
+ out: List[Dict[str, Any]] = []
203
+ for u in urls[:3]:
204
+ try:
205
+ resp = requests.get(u, timeout=self.timeout, allow_redirects=True)
206
+ ct = resp.headers.get("Content-Type", "")
207
+ item: Dict[str, Any] = {"url": u, "status": resp.status_code, "content_type": ct}
208
+
209
+ if "text/html" in ct or (not ct and resp.text.startswith("<!")):
210
+ ex = self._extract_text(resp.text)
211
+ if isinstance(max_chars, int) and max_chars > 0:
212
+ ex["text"] = ex["text"][:max_chars]
213
+ item.update(ex)
214
+ if return_html:
215
+ item["html"] = resp.text
216
+ elif "pdf" in ct or u.lower().endswith(".pdf"):
217
+ item["title"] = "(PDF Document)"
218
+ item["text"] = f"[PDF file: {u}]"
219
+ else:
220
+ item["title"] = ""
221
+ item["text"] = ""
222
+ out.append(item)
223
+ except requests.exceptions.RequestException as e:
224
+ out.append({"url": u, "status": 0, "content_type": "", "title": "", "text": "", "error": str(e)})
225
+
226
+ return {"results": out, "metadata": {"source": "ODPHP OutlinkFetch"}}
@@ -17,7 +17,7 @@ except FileNotFoundError as e:
17
17
  )
18
18
  sys.exit(1)
19
19
 
20
- server = FastMCP("Your MCP Server", stateless_http=True)
20
+ server = FastMCP("Your MCP Server")
21
21
  agents = {}
22
22
  for tool_config in boltz_tools:
23
23
  agents[tool_config["name"]] = Boltz2DockingTool(tool_config=tool_config)
@@ -47,4 +47,4 @@ def run_boltz2(query: dict):
47
47
 
48
48
 
49
49
  if __name__ == "__main__":
50
- server.run(transport="streamable-http", host="0.0.0.0", port=8080)
50
+ server.run(transport="streamable-http", host="0.0.0.0", port=8080, stateless_http=True)
@@ -18,7 +18,7 @@ except FileNotFoundError as e:
18
18
  )
19
19
  sys.exit(1)
20
20
 
21
- server = FastMCP("Your MCP Server", stateless_http=True)
21
+ server = FastMCP("Your MCP Server")
22
22
  agents = {}
23
23
  for tool_config in uspto_downloader_tools:
24
24
  agents[tool_config["name"]] = USPTOPatentDocumentDownloader(tool_config=tool_config)
@@ -58,4 +58,4 @@ def download_full_text(query: dict):
58
58
 
59
59
 
60
60
  if __name__ == "__main__":
61
- server.run(transport="streamable-http", host="0.0.0.0", port=8081)
61
+ server.run(transport="streamable-http", host="0.0.0.0", port=8081, stateless_http=True)