onetool-mcp 1.0.0b1__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 (132) hide show
  1. bench/__init__.py +5 -0
  2. bench/cli.py +69 -0
  3. bench/harness/__init__.py +66 -0
  4. bench/harness/client.py +692 -0
  5. bench/harness/config.py +397 -0
  6. bench/harness/csv_writer.py +109 -0
  7. bench/harness/evaluate.py +512 -0
  8. bench/harness/metrics.py +283 -0
  9. bench/harness/runner.py +899 -0
  10. bench/py.typed +0 -0
  11. bench/reporter.py +629 -0
  12. bench/run.py +487 -0
  13. bench/secrets.py +101 -0
  14. bench/utils.py +16 -0
  15. onetool/__init__.py +4 -0
  16. onetool/cli.py +391 -0
  17. onetool/py.typed +0 -0
  18. onetool_mcp-1.0.0b1.dist-info/METADATA +163 -0
  19. onetool_mcp-1.0.0b1.dist-info/RECORD +132 -0
  20. onetool_mcp-1.0.0b1.dist-info/WHEEL +4 -0
  21. onetool_mcp-1.0.0b1.dist-info/entry_points.txt +3 -0
  22. onetool_mcp-1.0.0b1.dist-info/licenses/LICENSE.txt +687 -0
  23. onetool_mcp-1.0.0b1.dist-info/licenses/NOTICE.txt +64 -0
  24. ot/__init__.py +37 -0
  25. ot/__main__.py +6 -0
  26. ot/_cli.py +107 -0
  27. ot/_tui.py +53 -0
  28. ot/config/__init__.py +46 -0
  29. ot/config/defaults/bench.yaml +4 -0
  30. ot/config/defaults/diagram-templates/api-flow.mmd +33 -0
  31. ot/config/defaults/diagram-templates/c4-context.puml +30 -0
  32. ot/config/defaults/diagram-templates/class-diagram.mmd +87 -0
  33. ot/config/defaults/diagram-templates/feature-mindmap.mmd +70 -0
  34. ot/config/defaults/diagram-templates/microservices.d2 +81 -0
  35. ot/config/defaults/diagram-templates/project-gantt.mmd +37 -0
  36. ot/config/defaults/diagram-templates/state-machine.mmd +42 -0
  37. ot/config/defaults/onetool.yaml +25 -0
  38. ot/config/defaults/prompts.yaml +97 -0
  39. ot/config/defaults/servers.yaml +7 -0
  40. ot/config/defaults/snippets.yaml +4 -0
  41. ot/config/defaults/tool_templates/__init__.py +7 -0
  42. ot/config/defaults/tool_templates/extension.py +52 -0
  43. ot/config/defaults/tool_templates/isolated.py +61 -0
  44. ot/config/dynamic.py +121 -0
  45. ot/config/global_templates/__init__.py +2 -0
  46. ot/config/global_templates/bench-secrets-template.yaml +6 -0
  47. ot/config/global_templates/bench.yaml +9 -0
  48. ot/config/global_templates/onetool.yaml +27 -0
  49. ot/config/global_templates/secrets-template.yaml +44 -0
  50. ot/config/global_templates/servers.yaml +18 -0
  51. ot/config/global_templates/snippets.yaml +235 -0
  52. ot/config/loader.py +1087 -0
  53. ot/config/mcp.py +145 -0
  54. ot/config/secrets.py +190 -0
  55. ot/config/tool_config.py +125 -0
  56. ot/decorators.py +116 -0
  57. ot/executor/__init__.py +35 -0
  58. ot/executor/base.py +16 -0
  59. ot/executor/fence_processor.py +83 -0
  60. ot/executor/linter.py +142 -0
  61. ot/executor/pack_proxy.py +260 -0
  62. ot/executor/param_resolver.py +140 -0
  63. ot/executor/pep723.py +288 -0
  64. ot/executor/result_store.py +369 -0
  65. ot/executor/runner.py +496 -0
  66. ot/executor/simple.py +163 -0
  67. ot/executor/tool_loader.py +396 -0
  68. ot/executor/validator.py +398 -0
  69. ot/executor/worker_pool.py +388 -0
  70. ot/executor/worker_proxy.py +189 -0
  71. ot/http_client.py +145 -0
  72. ot/logging/__init__.py +37 -0
  73. ot/logging/config.py +315 -0
  74. ot/logging/entry.py +213 -0
  75. ot/logging/format.py +188 -0
  76. ot/logging/span.py +349 -0
  77. ot/meta.py +1555 -0
  78. ot/paths.py +453 -0
  79. ot/prompts.py +218 -0
  80. ot/proxy/__init__.py +21 -0
  81. ot/proxy/manager.py +396 -0
  82. ot/py.typed +0 -0
  83. ot/registry/__init__.py +189 -0
  84. ot/registry/models.py +57 -0
  85. ot/registry/parser.py +269 -0
  86. ot/registry/registry.py +413 -0
  87. ot/server.py +315 -0
  88. ot/shortcuts/__init__.py +15 -0
  89. ot/shortcuts/aliases.py +87 -0
  90. ot/shortcuts/snippets.py +258 -0
  91. ot/stats/__init__.py +35 -0
  92. ot/stats/html.py +250 -0
  93. ot/stats/jsonl_writer.py +283 -0
  94. ot/stats/reader.py +354 -0
  95. ot/stats/timing.py +57 -0
  96. ot/support.py +63 -0
  97. ot/tools.py +114 -0
  98. ot/utils/__init__.py +81 -0
  99. ot/utils/batch.py +161 -0
  100. ot/utils/cache.py +120 -0
  101. ot/utils/deps.py +403 -0
  102. ot/utils/exceptions.py +23 -0
  103. ot/utils/factory.py +179 -0
  104. ot/utils/format.py +65 -0
  105. ot/utils/http.py +202 -0
  106. ot/utils/platform.py +45 -0
  107. ot/utils/sanitize.py +130 -0
  108. ot/utils/truncate.py +69 -0
  109. ot_tools/__init__.py +4 -0
  110. ot_tools/_convert/__init__.py +12 -0
  111. ot_tools/_convert/excel.py +279 -0
  112. ot_tools/_convert/pdf.py +254 -0
  113. ot_tools/_convert/powerpoint.py +268 -0
  114. ot_tools/_convert/utils.py +358 -0
  115. ot_tools/_convert/word.py +283 -0
  116. ot_tools/brave_search.py +604 -0
  117. ot_tools/code_search.py +736 -0
  118. ot_tools/context7.py +495 -0
  119. ot_tools/convert.py +614 -0
  120. ot_tools/db.py +415 -0
  121. ot_tools/diagram.py +1604 -0
  122. ot_tools/diagram.yaml +167 -0
  123. ot_tools/excel.py +1372 -0
  124. ot_tools/file.py +1348 -0
  125. ot_tools/firecrawl.py +732 -0
  126. ot_tools/grounding_search.py +646 -0
  127. ot_tools/package.py +604 -0
  128. ot_tools/py.typed +0 -0
  129. ot_tools/ripgrep.py +544 -0
  130. ot_tools/scaffold.py +471 -0
  131. ot_tools/transform.py +213 -0
  132. ot_tools/web_fetch.py +384 -0
ot_tools/package.py ADDED
@@ -0,0 +1,604 @@
1
+ """Package version tools.
2
+
3
+ Check latest versions for npm, PyPI packages and search OpenRouter AI models.
4
+ No API keys required.
5
+
6
+ Attribution: Based on mcp-package-version by Sam McLeod - MIT License
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ # Pack for dot notation: package.version(), package.npm(), etc.
12
+ pack = "package"
13
+
14
+ __all__ = ["audit", "models", "npm", "pypi", "version"]
15
+
16
+ import re
17
+ from concurrent.futures import ThreadPoolExecutor
18
+ from datetime import UTC
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from pydantic import BaseModel, Field
23
+
24
+ from ot.config import get_tool_config
25
+ from ot.http_client import http_get
26
+ from ot.logging import LogSpan
27
+
28
+
29
+ class Config(BaseModel):
30
+ """Pack configuration - discovered by registry."""
31
+
32
+ timeout: float = Field(
33
+ default=30.0,
34
+ ge=1.0,
35
+ le=120.0,
36
+ description="Request timeout in seconds",
37
+ )
38
+
39
+ NPM_REGISTRY = "https://registry.npmjs.org"
40
+ PYPI_API = "https://pypi.org/pypi"
41
+ OPENROUTER_API = "https://openrouter.ai/api/v1/models"
42
+
43
+
44
+ def _clean_version(version: str) -> str:
45
+ """Strip semver range prefixes (^, ~, >=, etc.) from version string."""
46
+ return re.sub(r"^[\^~>=<]+", "", version)
47
+
48
+
49
+ def _parse_version_constraint(constraint: str) -> str | None:
50
+ """Extract version from a constraint string like 'requests>=2.28.0' or '>=2.28.0'.
51
+
52
+ Returns the version part or None if no version found.
53
+ """
54
+ # Match patterns like: >=2.28.0, ==1.0.0, ~=3.0, ^18.0.0, etc.
55
+ match = re.search(r"[\^~>=<!=]+\s*(\d+[.\d]*[a-zA-Z0-9.-]*)", constraint)
56
+ if match:
57
+ return match.group(1)
58
+ # Match bare version like "2.28.0"
59
+ match = re.match(r"^(\d+[.\d]*[a-zA-Z0-9.-]*)$", constraint.strip())
60
+ if match:
61
+ return match.group(1)
62
+ return None
63
+
64
+
65
+ def _parse_pyproject_toml(path: Path) -> dict[str, str]:
66
+ """Parse dependencies from pyproject.toml.
67
+
68
+ Extracts from:
69
+ - project.dependencies
70
+ - project.optional-dependencies.*
71
+ - dependency-groups.*
72
+ """
73
+ try:
74
+ import tomllib
75
+ except ImportError:
76
+ import tomli as tomllib # type: ignore[import-not-found]
77
+
78
+ content = path.read_text()
79
+ data = tomllib.loads(content)
80
+
81
+ deps: dict[str, str] = {}
82
+
83
+ # project.dependencies
84
+ project = data.get("project", {})
85
+ for dep in project.get("dependencies", []):
86
+ name, ver = _parse_dependency_string(dep)
87
+ if name:
88
+ deps[name] = ver
89
+
90
+ # project.optional-dependencies
91
+ for group_deps in project.get("optional-dependencies", {}).values():
92
+ for dep in group_deps:
93
+ name, ver = _parse_dependency_string(dep)
94
+ if name:
95
+ deps[name] = ver
96
+
97
+ # dependency-groups (PEP 735)
98
+ for group_deps in data.get("dependency-groups", {}).values():
99
+ for dep in group_deps:
100
+ # Skip include-group references like {"include-group": "dev"}
101
+ if isinstance(dep, str):
102
+ name, ver = _parse_dependency_string(dep)
103
+ if name:
104
+ deps[name] = ver
105
+
106
+ return deps
107
+
108
+
109
+ def _parse_dependency_string(dep: str) -> tuple[str | None, str]:
110
+ """Parse a PEP 508 dependency string like 'requests>=2.28.0' or 'requests[security]>=2.28.0'.
111
+
112
+ Returns (name, version_constraint) or (None, "") if invalid.
113
+ """
114
+ # Remove extras like [security] and environment markers
115
+ dep = dep.split(";")[0].strip() # Remove environment markers
116
+
117
+ # Match: name[extras]version_spec or name version_spec
118
+ match = re.match(r"^([a-zA-Z0-9_-]+)(?:\[[^\]]+\])?\s*(.*)$", dep)
119
+ if match:
120
+ name = match.group(1).lower().replace("_", "-")
121
+ version_spec = match.group(2).strip()
122
+ return name, version_spec
123
+ return None, ""
124
+
125
+
126
+ def _parse_requirements_txt(path: Path) -> dict[str, str]:
127
+ """Parse dependencies from requirements.txt."""
128
+ deps: dict[str, str] = {}
129
+
130
+ for line in path.read_text().splitlines():
131
+ line = line.strip()
132
+ # Skip empty lines, comments, and options
133
+ if not line or line.startswith("#") or line.startswith("-"):
134
+ continue
135
+ # Skip editable installs
136
+ if line.startswith("git+") or line.startswith("http"):
137
+ continue
138
+
139
+ name, ver = _parse_dependency_string(line)
140
+ if name:
141
+ deps[name] = ver
142
+
143
+ return deps
144
+
145
+
146
+ def _parse_package_json(path: Path) -> dict[str, str]:
147
+ """Parse dependencies from package.json.
148
+
149
+ Extracts from:
150
+ - dependencies
151
+ - devDependencies
152
+ """
153
+ import json
154
+
155
+ data = json.loads(path.read_text())
156
+ deps: dict[str, str] = {}
157
+
158
+ for section in ("dependencies", "devDependencies"):
159
+ for name, ver in data.get(section, {}).items():
160
+ deps[name] = ver
161
+
162
+ return deps
163
+
164
+
165
+ def _compare_versions(current: str | None, latest: str) -> str:
166
+ """Compare current version against latest and return status.
167
+
168
+ Returns: "current", "update_available", "major_update", or "unknown"
169
+ """
170
+ if not current or latest == "unknown":
171
+ return "unknown"
172
+
173
+ # Clean version strings
174
+ current_clean = _clean_version(current)
175
+ latest_clean = latest
176
+
177
+ if current_clean == latest_clean:
178
+ return "current"
179
+
180
+ # Try to parse semver for major version comparison
181
+ current_parts = current_clean.split(".")
182
+ latest_parts = latest_clean.split(".")
183
+
184
+ try:
185
+ current_match = re.match(r"(\d+)", current_parts[0])
186
+ latest_match = re.match(r"(\d+)", latest_parts[0])
187
+
188
+ if current_match and latest_match:
189
+ current_major = int(current_match.group(1))
190
+ latest_major = int(latest_match.group(1))
191
+
192
+ if latest_major > current_major:
193
+ return "major_update"
194
+ except (ValueError, IndexError):
195
+ pass
196
+
197
+ return "update_available"
198
+
199
+
200
+ def _detect_manifest(path: Path) -> tuple[str, Path] | None:
201
+ """Auto-detect manifest file in directory.
202
+
203
+ Returns (registry, manifest_path) or None if not found.
204
+ """
205
+ # Check in order of preference
206
+ pyproject = path / "pyproject.toml"
207
+ if pyproject.exists():
208
+ return "pypi", pyproject
209
+
210
+ requirements = path / "requirements.txt"
211
+ if requirements.exists():
212
+ return "pypi", requirements
213
+
214
+ package_json = path / "package.json"
215
+ if package_json.exists():
216
+ return "npm", package_json
217
+
218
+ return None
219
+
220
+
221
+ def audit(
222
+ *,
223
+ path: str = ".",
224
+ registry: str | None = None,
225
+ ) -> dict[str, Any]:
226
+ """Audit project dependencies against latest registry versions.
227
+
228
+ Auto-detects manifest files (pyproject.toml, requirements.txt, package.json)
229
+ and compares current versions against the latest available.
230
+
231
+ Args:
232
+ path: Project directory path (default: current directory)
233
+ registry: Force specific registry ("npm" or "pypi"). Auto-detects if not specified.
234
+
235
+ Returns:
236
+ Dict with manifest, registry, packages list, and summary counts.
237
+ Each package has: name, required, latest, status.
238
+
239
+ Example:
240
+ package.audit()
241
+ package.audit(path="./frontend", registry="npm")
242
+ """
243
+ with LogSpan(span="package.audit", path=path, registry=registry) as span:
244
+ base_path = Path(path).resolve()
245
+
246
+ # Determine registry and manifest
247
+ if registry:
248
+ # Explicit registry - find matching manifest
249
+ if registry == "npm":
250
+ manifest_path = base_path / "package.json"
251
+ if not manifest_path.exists():
252
+ span.add(error="manifest_not_found")
253
+ return {"error": f"No package.json found in {path}"}
254
+ elif registry == "pypi":
255
+ manifest_path = base_path / "pyproject.toml"
256
+ if not manifest_path.exists():
257
+ manifest_path = base_path / "requirements.txt"
258
+ if not manifest_path.exists():
259
+ span.add(error="manifest_not_found")
260
+ return {"error": f"No pyproject.toml or requirements.txt found in {path}"}
261
+ else:
262
+ span.add(error="invalid_registry")
263
+ return {"error": f"Invalid registry: {registry}. Use 'npm' or 'pypi'."}
264
+ else:
265
+ # Auto-detect
266
+ detected = _detect_manifest(base_path)
267
+ if not detected:
268
+ span.add(error="no_manifest")
269
+ return {
270
+ "error": f"No manifest found in {path}. Looking for: pyproject.toml, requirements.txt, package.json"
271
+ }
272
+ registry, manifest_path = detected
273
+
274
+ # Parse manifest
275
+ deps: dict[str, str] = {}
276
+ manifest_name = manifest_path.name
277
+
278
+ if manifest_name == "pyproject.toml":
279
+ deps = _parse_pyproject_toml(manifest_path)
280
+ elif manifest_name == "requirements.txt":
281
+ deps = _parse_requirements_txt(manifest_path)
282
+ elif manifest_name == "package.json":
283
+ deps = _parse_package_json(manifest_path)
284
+
285
+ if not deps:
286
+ span.add(error="no_dependencies")
287
+ return {"error": f"No dependencies found in {manifest_path}"}
288
+
289
+ span.add(count=len(deps))
290
+
291
+ # Fetch latest versions in parallel
292
+ with ThreadPoolExecutor(max_workers=min(len(deps), 20)) as executor:
293
+ futures = {
294
+ name: executor.submit(_fetch_package, registry, name, ver)
295
+ for name, ver in deps.items()
296
+ }
297
+ results = {name: f.result() for name, f in futures.items()}
298
+
299
+ # Build package list with status
300
+ packages: list[dict[str, Any]] = []
301
+ summary = {"current": 0, "update_available": 0, "major_update": 0, "unknown": 0}
302
+
303
+ for name in sorted(deps.keys()):
304
+ result = results[name]
305
+ required = deps[name] or "*"
306
+ latest = result.get("latest", "unknown")
307
+
308
+ # Get version from required constraint
309
+ current_ver = _parse_version_constraint(required) if required != "*" else None
310
+ status = _compare_versions(current_ver, latest)
311
+
312
+ packages.append(
313
+ {
314
+ "name": name,
315
+ "required": required,
316
+ "latest": latest,
317
+ "status": status,
318
+ }
319
+ )
320
+ summary[status] += 1
321
+
322
+ span.add(summary=summary)
323
+
324
+ return {
325
+ "manifest": str(manifest_path),
326
+ "registry": registry,
327
+ "packages": packages,
328
+ "summary": summary,
329
+ }
330
+
331
+
332
+ def _fetch(url: str, timeout: float | None = None) -> tuple[bool, dict[str, Any] | str]:
333
+ """Fetch JSON from URL.
334
+
335
+ Args:
336
+ url: URL to fetch
337
+ timeout: Request timeout in seconds (defaults to config)
338
+
339
+ Returns:
340
+ Tuple of (success, data_or_error)
341
+ """
342
+ if timeout is None:
343
+ timeout = get_tool_config("package", Config).timeout
344
+
345
+ with LogSpan(span="package.fetch", url=url) as span:
346
+ success, data = http_get(url, timeout=timeout)
347
+ span.add(success=success)
348
+ return success, data
349
+
350
+
351
+ def npm(*, packages: list[str]) -> list[dict[str, Any]]:
352
+ """Check latest npm package versions.
353
+
354
+ Args:
355
+ packages: List of npm package names
356
+
357
+ Returns:
358
+ List of dicts with name, registry, latest fields
359
+
360
+ Example:
361
+ package.npm(packages=["react", "lodash", "express"])
362
+ """
363
+ return version(registry="npm", packages=packages)
364
+
365
+
366
+ def pypi(*, packages: list[str]) -> list[dict[str, Any]]:
367
+ """Check latest PyPI package versions.
368
+
369
+ Args:
370
+ packages: List of Python package names
371
+
372
+ Returns:
373
+ List of dicts with name, registry, latest fields
374
+
375
+ Example:
376
+ package.pypi(packages=["requests", "flask", "fastapi"])
377
+ """
378
+ # Delegate to version() for parallel fetching
379
+ return version(registry="pypi", packages=packages)
380
+
381
+
382
+ def _format_price(price: float | str | None) -> str:
383
+ """Format price as $/MTok."""
384
+ if price is None:
385
+ return "N/A"
386
+ try:
387
+ price_float = float(price)
388
+ except (ValueError, TypeError):
389
+ return "N/A"
390
+ # Price is per token, convert to per million tokens
391
+ mtok = price_float * 1_000_000
392
+ if mtok < 0.01:
393
+ return f"${mtok:.4f}/MTok"
394
+ return f"${mtok:.2f}/MTok"
395
+
396
+
397
+ def models(
398
+ *,
399
+ query: str = "",
400
+ provider: str = "",
401
+ limit: int = 20,
402
+ ) -> list[dict[str, Any]]:
403
+ """Search OpenRouter AI models.
404
+
405
+ Args:
406
+ query: Search query for model name/id (case-insensitive)
407
+ provider: Filter by provider (e.g., "anthropic", "openai")
408
+ limit: Maximum results to return (default: 20)
409
+
410
+ Returns:
411
+ List of model dicts with id, name, context_length, pricing, modality
412
+
413
+ Example:
414
+ # Search by name
415
+ package.models(query="claude")
416
+
417
+ # Filter by provider
418
+ package.models(provider="anthropic", limit=5)
419
+ """
420
+ with LogSpan(span="package.models", query=query, provider=provider):
421
+ ok, data = _fetch(OPENROUTER_API)
422
+ if not ok or not isinstance(data, dict):
423
+ return []
424
+
425
+ models_data = data.get("data", [])
426
+ results = []
427
+
428
+ query_lower = query.lower()
429
+ provider_lower = provider.lower()
430
+
431
+ for model in models_data:
432
+ model_id = model.get("id", "")
433
+ model_name = model.get("name", "")
434
+
435
+ # Filter by query
436
+ if (
437
+ query_lower
438
+ and query_lower not in model_id.lower()
439
+ and query_lower not in model_name.lower()
440
+ ):
441
+ continue
442
+
443
+ # Filter by provider
444
+ if provider_lower and not model_id.lower().startswith(provider_lower + "/"):
445
+ continue
446
+
447
+ pricing = model.get("pricing", {})
448
+ architecture = model.get("architecture", {})
449
+
450
+ results.append(
451
+ {
452
+ "id": model_id,
453
+ "name": model_name,
454
+ "context_length": model.get("context_length"),
455
+ "pricing": {
456
+ "prompt": _format_price(pricing.get("prompt")),
457
+ "completion": _format_price(pricing.get("completion")),
458
+ },
459
+ "modality": architecture.get("modality", "text->text"),
460
+ }
461
+ )
462
+
463
+ if len(results) >= limit:
464
+ break
465
+
466
+ return results
467
+
468
+
469
+ def _fetch_package(
470
+ registry: str, pkg: str, current: str | None = None
471
+ ) -> dict[str, Any]:
472
+ """Fetch single package version from npm or pypi."""
473
+ if registry == "npm":
474
+ ok, data = _fetch(f"{NPM_REGISTRY}/{pkg}")
475
+ latest = (
476
+ data.get("dist-tags", {}).get("latest", "unknown")
477
+ if ok and isinstance(data, dict)
478
+ else "unknown"
479
+ )
480
+ else: # pypi
481
+ ok, data = _fetch(f"{PYPI_API}/{pkg}/json")
482
+ latest = (
483
+ data.get("info", {}).get("version", "unknown")
484
+ if ok and isinstance(data, dict)
485
+ else "unknown"
486
+ )
487
+
488
+ result: dict[str, Any] = {"name": pkg, "registry": registry, "latest": latest}
489
+ if current is not None:
490
+ result["current"] = _clean_version(current)
491
+ return result
492
+
493
+
494
+ def _format_created(timestamp: int | None) -> str:
495
+ """Format Unix timestamp as yyyymmdd."""
496
+ if not timestamp:
497
+ return "unknown"
498
+ from datetime import datetime
499
+
500
+ dt = datetime.fromtimestamp(timestamp, tz=UTC)
501
+ return dt.strftime("%Y%m%d")
502
+
503
+
504
+ def _fetch_model(query: str, all_models: list[dict[str, Any]]) -> dict[str, Any] | None:
505
+ """Find first matching model by wildcard pattern or contains check.
506
+
507
+ Supports glob-style wildcards:
508
+ "openai/gpt-5.*" - matches openai/gpt-5.1, openai/gpt-5.2, etc.
509
+ "google/gemini-*-flash-*" - matches gemini flash variants
510
+ "anthropic/claude-sonnet-4.*" - matches claude-sonnet-4.x versions
511
+ """
512
+ from fnmatch import fnmatch
513
+
514
+ query_lower = query.lower()
515
+ use_glob = "*" in query
516
+
517
+ for model in all_models:
518
+ model_id = model.get("id", "")
519
+ model_id_lower = model_id.lower()
520
+
521
+ # Match: glob pattern or contains check
522
+ if use_glob:
523
+ matched = fnmatch(model_id_lower, query_lower)
524
+ else:
525
+ matched = query_lower in model_id_lower
526
+
527
+ if matched:
528
+ pricing = model.get("pricing", {})
529
+ return {
530
+ "query": query,
531
+ "registry": "openrouter",
532
+ "id": model_id,
533
+ "name": model.get("name", ""),
534
+ "created": _format_created(model.get("created")),
535
+ "context_length": model.get("context_length"),
536
+ "pricing": {
537
+ "prompt": _format_price(pricing.get("prompt")),
538
+ "completion": _format_price(pricing.get("completion")),
539
+ },
540
+ }
541
+ return {
542
+ "query": query,
543
+ "registry": "openrouter",
544
+ "id": "unknown",
545
+ "created": "unknown",
546
+ }
547
+
548
+
549
+ def version(
550
+ *,
551
+ registry: str,
552
+ packages: list[str] | dict[str, str],
553
+ ) -> list[dict[str, Any]] | str:
554
+ """Check latest versions for packages from a registry.
555
+
556
+ Args:
557
+ registry: Package registry - "npm", "pypi", or "openrouter"
558
+ packages: List of package names, or dict mapping names to current versions
559
+
560
+ Returns:
561
+ List of version result dicts. If current versions provided,
562
+ includes both 'current' and 'latest' fields.
563
+
564
+ Examples:
565
+ # Just get latest versions
566
+ package.version(registry="npm", packages=["react", "lodash"])
567
+ package.version(registry="pypi", packages=["requests", "flask"])
568
+ package.version(registry="openrouter", packages=["claude", "gpt-4"])
569
+
570
+ # Provide current versions, get both current and latest
571
+ package.version(registry="npm", packages={"react": "^18.0.0", "lodash": "^4.0.0"})
572
+ package.version(registry="pypi", packages={"requests": "2.31.0", "flask": "3.0.0"})
573
+ """
574
+ # Normalize input: convert dict to list of tuples (name, current_version)
575
+ if isinstance(packages, dict):
576
+ pkg_list = [(name, ver) for name, ver in packages.items()]
577
+ else:
578
+ pkg_list = [(name, None) for name in packages]
579
+
580
+ with LogSpan(span="package.version", registry=registry, count=len(pkg_list)):
581
+ results: list[dict[str, Any]] = []
582
+
583
+ if registry in ("npm", "pypi"):
584
+ with ThreadPoolExecutor(max_workers=min(len(pkg_list), 20)) as executor:
585
+ futures = [
586
+ executor.submit(_fetch_package, registry, pkg, current)
587
+ for pkg, current in pkg_list
588
+ ]
589
+ results = [f.result() for f in futures]
590
+
591
+ elif registry == "openrouter":
592
+ ok, data = _fetch(OPENROUTER_API)
593
+ all_models: list[dict[str, Any]] = []
594
+ if ok and isinstance(data, dict):
595
+ all_models = data.get("data", [])
596
+ for q, _ in pkg_list:
597
+ r = _fetch_model(q, all_models)
598
+ if r:
599
+ results.append(r)
600
+
601
+ else:
602
+ return f"Unknown registry: {registry}. Use npm, pypi, or openrouter."
603
+
604
+ return results
ot_tools/py.typed ADDED
File without changes