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.
- bench/__init__.py +5 -0
- bench/cli.py +69 -0
- bench/harness/__init__.py +66 -0
- bench/harness/client.py +692 -0
- bench/harness/config.py +397 -0
- bench/harness/csv_writer.py +109 -0
- bench/harness/evaluate.py +512 -0
- bench/harness/metrics.py +283 -0
- bench/harness/runner.py +899 -0
- bench/py.typed +0 -0
- bench/reporter.py +629 -0
- bench/run.py +487 -0
- bench/secrets.py +101 -0
- bench/utils.py +16 -0
- onetool/__init__.py +4 -0
- onetool/cli.py +391 -0
- onetool/py.typed +0 -0
- onetool_mcp-1.0.0b1.dist-info/METADATA +163 -0
- onetool_mcp-1.0.0b1.dist-info/RECORD +132 -0
- onetool_mcp-1.0.0b1.dist-info/WHEEL +4 -0
- onetool_mcp-1.0.0b1.dist-info/entry_points.txt +3 -0
- onetool_mcp-1.0.0b1.dist-info/licenses/LICENSE.txt +687 -0
- onetool_mcp-1.0.0b1.dist-info/licenses/NOTICE.txt +64 -0
- ot/__init__.py +37 -0
- ot/__main__.py +6 -0
- ot/_cli.py +107 -0
- ot/_tui.py +53 -0
- ot/config/__init__.py +46 -0
- ot/config/defaults/bench.yaml +4 -0
- ot/config/defaults/diagram-templates/api-flow.mmd +33 -0
- ot/config/defaults/diagram-templates/c4-context.puml +30 -0
- ot/config/defaults/diagram-templates/class-diagram.mmd +87 -0
- ot/config/defaults/diagram-templates/feature-mindmap.mmd +70 -0
- ot/config/defaults/diagram-templates/microservices.d2 +81 -0
- ot/config/defaults/diagram-templates/project-gantt.mmd +37 -0
- ot/config/defaults/diagram-templates/state-machine.mmd +42 -0
- ot/config/defaults/onetool.yaml +25 -0
- ot/config/defaults/prompts.yaml +97 -0
- ot/config/defaults/servers.yaml +7 -0
- ot/config/defaults/snippets.yaml +4 -0
- ot/config/defaults/tool_templates/__init__.py +7 -0
- ot/config/defaults/tool_templates/extension.py +52 -0
- ot/config/defaults/tool_templates/isolated.py +61 -0
- ot/config/dynamic.py +121 -0
- ot/config/global_templates/__init__.py +2 -0
- ot/config/global_templates/bench-secrets-template.yaml +6 -0
- ot/config/global_templates/bench.yaml +9 -0
- ot/config/global_templates/onetool.yaml +27 -0
- ot/config/global_templates/secrets-template.yaml +44 -0
- ot/config/global_templates/servers.yaml +18 -0
- ot/config/global_templates/snippets.yaml +235 -0
- ot/config/loader.py +1087 -0
- ot/config/mcp.py +145 -0
- ot/config/secrets.py +190 -0
- ot/config/tool_config.py +125 -0
- ot/decorators.py +116 -0
- ot/executor/__init__.py +35 -0
- ot/executor/base.py +16 -0
- ot/executor/fence_processor.py +83 -0
- ot/executor/linter.py +142 -0
- ot/executor/pack_proxy.py +260 -0
- ot/executor/param_resolver.py +140 -0
- ot/executor/pep723.py +288 -0
- ot/executor/result_store.py +369 -0
- ot/executor/runner.py +496 -0
- ot/executor/simple.py +163 -0
- ot/executor/tool_loader.py +396 -0
- ot/executor/validator.py +398 -0
- ot/executor/worker_pool.py +388 -0
- ot/executor/worker_proxy.py +189 -0
- ot/http_client.py +145 -0
- ot/logging/__init__.py +37 -0
- ot/logging/config.py +315 -0
- ot/logging/entry.py +213 -0
- ot/logging/format.py +188 -0
- ot/logging/span.py +349 -0
- ot/meta.py +1555 -0
- ot/paths.py +453 -0
- ot/prompts.py +218 -0
- ot/proxy/__init__.py +21 -0
- ot/proxy/manager.py +396 -0
- ot/py.typed +0 -0
- ot/registry/__init__.py +189 -0
- ot/registry/models.py +57 -0
- ot/registry/parser.py +269 -0
- ot/registry/registry.py +413 -0
- ot/server.py +315 -0
- ot/shortcuts/__init__.py +15 -0
- ot/shortcuts/aliases.py +87 -0
- ot/shortcuts/snippets.py +258 -0
- ot/stats/__init__.py +35 -0
- ot/stats/html.py +250 -0
- ot/stats/jsonl_writer.py +283 -0
- ot/stats/reader.py +354 -0
- ot/stats/timing.py +57 -0
- ot/support.py +63 -0
- ot/tools.py +114 -0
- ot/utils/__init__.py +81 -0
- ot/utils/batch.py +161 -0
- ot/utils/cache.py +120 -0
- ot/utils/deps.py +403 -0
- ot/utils/exceptions.py +23 -0
- ot/utils/factory.py +179 -0
- ot/utils/format.py +65 -0
- ot/utils/http.py +202 -0
- ot/utils/platform.py +45 -0
- ot/utils/sanitize.py +130 -0
- ot/utils/truncate.py +69 -0
- ot_tools/__init__.py +4 -0
- ot_tools/_convert/__init__.py +12 -0
- ot_tools/_convert/excel.py +279 -0
- ot_tools/_convert/pdf.py +254 -0
- ot_tools/_convert/powerpoint.py +268 -0
- ot_tools/_convert/utils.py +358 -0
- ot_tools/_convert/word.py +283 -0
- ot_tools/brave_search.py +604 -0
- ot_tools/code_search.py +736 -0
- ot_tools/context7.py +495 -0
- ot_tools/convert.py +614 -0
- ot_tools/db.py +415 -0
- ot_tools/diagram.py +1604 -0
- ot_tools/diagram.yaml +167 -0
- ot_tools/excel.py +1372 -0
- ot_tools/file.py +1348 -0
- ot_tools/firecrawl.py +732 -0
- ot_tools/grounding_search.py +646 -0
- ot_tools/package.py +604 -0
- ot_tools/py.typed +0 -0
- ot_tools/ripgrep.py +544 -0
- ot_tools/scaffold.py +471 -0
- ot_tools/transform.py +213 -0
- 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
|