asm-protocol 0.5.0__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.
- asm_cli.py +626 -0
- asm_protocol-0.5.0.dist-info/METADATA +395 -0
- asm_protocol-0.5.0.dist-info/RECORD +21 -0
- asm_protocol-0.5.0.dist-info/WHEEL +5 -0
- asm_protocol-0.5.0.dist-info/entry_points.txt +4 -0
- asm_protocol-0.5.0.dist-info/licenses/LICENSE +21 -0
- asm_protocol-0.5.0.dist-info/top_level.txt +7 -0
- asm_select_api.py +144 -0
- asm_selector_mcp.py +94 -0
- library_select.py +149 -0
- mcp_server_json_asm.py +177 -0
- openrouter_adapter.py +421 -0
- scorer/__init__.py +45 -0
- scorer/data/elo_snapshot.json +5027 -0
- scorer/scorer.py +883 -0
- scorer/test_langchain_adapter.py +54 -0
- scorer/test_library_select.py +104 -0
- scorer/test_manifests_schema.py +55 -0
- scorer/test_mcp_server_json_asm.py +84 -0
- scorer/test_openrouter_adapter.py +238 -0
- scorer/test_scorer.py +447 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Tests for the LangChain adapter (ASMToolSelectorTool). Skipped if langchain-core absent."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
pytest.importorskip("langchain_core")
|
|
10
|
+
|
|
11
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
12
|
+
if str(ROOT) not in sys.path:
|
|
13
|
+
sys.path.insert(0, str(ROOT))
|
|
14
|
+
sys.path.insert(0, str(ROOT / "integrations" / "langchain"))
|
|
15
|
+
|
|
16
|
+
from asm_tools import ASMToolSelectorTool # noqa: E402
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_selector_tool_booking_flow():
|
|
20
|
+
tool = ASMToolSelectorTool()
|
|
21
|
+
out = tool._run(
|
|
22
|
+
task="find and book a refundable flight",
|
|
23
|
+
taxonomy="tool.booking.travel",
|
|
24
|
+
user_platform="windows",
|
|
25
|
+
required_functions="flight_search,flight_order_create",
|
|
26
|
+
)
|
|
27
|
+
assert "Amadeus" in out
|
|
28
|
+
assert "risk_class: critical" in out
|
|
29
|
+
assert "approval_required: True" in out
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_selector_tool_setup_gate():
|
|
33
|
+
tool = ASMToolSelectorTool()
|
|
34
|
+
out = tool._run(
|
|
35
|
+
task="get property data",
|
|
36
|
+
taxonomy="tool.data.real_estate",
|
|
37
|
+
required_functions="real_estate_data",
|
|
38
|
+
require_agent_completable_setup=True,
|
|
39
|
+
)
|
|
40
|
+
assert "US Census Bureau Data API" in out
|
|
41
|
+
assert "setup not agent-completable" in out
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_selector_tool_is_valid_langchain_tool():
|
|
45
|
+
tool = ASMToolSelectorTool()
|
|
46
|
+
assert tool.name == "asm_tool_selector"
|
|
47
|
+
schema = tool.args_schema.model_json_schema()
|
|
48
|
+
assert "task" in schema["properties"]
|
|
49
|
+
# invoke via the LangChain interface, not just _run
|
|
50
|
+
out = tool.invoke({"task": "store a study plan with daily reminders",
|
|
51
|
+
"taxonomy": "tool.productivity.task_management",
|
|
52
|
+
"user_platform": "windows",
|
|
53
|
+
"required_functions": "reminders,recurring_tasks"})
|
|
54
|
+
assert "Selected tool:" in out
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Tests for the shared tool selector (library_select) and the hosted select API."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
import urllib.request
|
|
8
|
+
from http.server import ThreadingHTTPServer
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
12
|
+
if str(ROOT) not in sys.path:
|
|
13
|
+
sys.path.insert(0, str(ROOT))
|
|
14
|
+
|
|
15
|
+
from library_select import load_library, monthly_cost, select # noqa: E402
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_library_loads_and_validates_shape():
|
|
19
|
+
lib = load_library()
|
|
20
|
+
assert len(lib) >= 30
|
|
21
|
+
assert all("service_id" in m and "taxonomy" in m for m in lib)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_local_device_tools_filtered_for_cloud_agent():
|
|
25
|
+
r = select("store a study plan", taxonomy="tool.productivity.task_management",
|
|
26
|
+
agent_reach="cloud", user_platform="windows",
|
|
27
|
+
required_functions=["reminders", "recurring_tasks"])
|
|
28
|
+
assert r["selected"] is not None
|
|
29
|
+
rejected_names = {x["service"] for x in r["rejected"]}
|
|
30
|
+
assert "Things 3" in rejected_names and "Apple Reminders" in rejected_names
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_booking_surfaces_critical_risk_and_approval():
|
|
34
|
+
r = select("book a flight", taxonomy="tool.booking.travel",
|
|
35
|
+
agent_reach="cloud", user_platform="windows",
|
|
36
|
+
required_functions=["flight_search", "flight_order_create"],
|
|
37
|
+
require_approval_for=["financial_charge"])
|
|
38
|
+
assert r["selected"] is not None
|
|
39
|
+
assert r["risk_class"] == "critical"
|
|
40
|
+
assert r["approval_required"] is True
|
|
41
|
+
assert "financial_charge" in r["side_effects"]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_agent_completable_setup_gate():
|
|
45
|
+
r = select("property data", taxonomy="tool.data.real_estate",
|
|
46
|
+
agent_reach="cloud", user_platform="windows",
|
|
47
|
+
required_functions=["real_estate_data"],
|
|
48
|
+
require_agent_completable_setup=True)
|
|
49
|
+
assert r["selected"]["display_name"] == "US Census Bureau Data API"
|
|
50
|
+
reasons = " ".join(x["reason"] for x in r["rejected"])
|
|
51
|
+
assert "setup not agent-completable" in reasons
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_monthly_cost_free_tier_is_zero():
|
|
55
|
+
lib = load_library()
|
|
56
|
+
todoist = next(m for m in lib if m["service_id"].startswith("todoist/"))
|
|
57
|
+
assert monthly_cost(todoist) == 0.0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_select_api_endpoints():
|
|
61
|
+
import asm_select_api as api
|
|
62
|
+
|
|
63
|
+
srv = ThreadingHTTPServer(("127.0.0.1", 0), api.Handler)
|
|
64
|
+
port = srv.server_address[1]
|
|
65
|
+
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
|
66
|
+
try:
|
|
67
|
+
h = json.loads(urllib.request.urlopen(f"http://127.0.0.1:{port}/healthz").read())
|
|
68
|
+
assert h["ok"] and h["tools"] >= 30
|
|
69
|
+
|
|
70
|
+
tools = json.loads(urllib.request.urlopen(
|
|
71
|
+
f"http://127.0.0.1:{port}/tools?taxonomy=tool.data.real_estate").read())
|
|
72
|
+
assert len(tools) == 4
|
|
73
|
+
|
|
74
|
+
body = json.dumps({"task": "book a flight", "taxonomy": "tool.booking.travel",
|
|
75
|
+
"user_platform": "windows",
|
|
76
|
+
"required_functions": ["flight_search", "flight_order_create"]}).encode()
|
|
77
|
+
req = urllib.request.Request(f"http://127.0.0.1:{port}/select", data=body,
|
|
78
|
+
headers={"Content-Type": "application/json"})
|
|
79
|
+
r = json.loads(urllib.request.urlopen(req).read())
|
|
80
|
+
assert r["selected"] is not None and r["risk_class"] == "critical"
|
|
81
|
+
|
|
82
|
+
bad = urllib.request.Request(f"http://127.0.0.1:{port}/select", data=b"{}",
|
|
83
|
+
headers={"Content-Type": "application/json"})
|
|
84
|
+
try:
|
|
85
|
+
urllib.request.urlopen(bad)
|
|
86
|
+
assert False, "expected 400"
|
|
87
|
+
except urllib.error.HTTPError as e:
|
|
88
|
+
assert e.code == 400
|
|
89
|
+
|
|
90
|
+
# .well-known/asm catalog: one generated_at, per-manifest links
|
|
91
|
+
cat = json.loads(urllib.request.urlopen(
|
|
92
|
+
f"http://127.0.0.1:{port}/.well-known/asm").read())
|
|
93
|
+
assert cat["count"] >= 30 and cat["generated_at"]
|
|
94
|
+
assert all("service_id" in e and e["url"].startswith("/manifest/")
|
|
95
|
+
for e in cat["manifests"])
|
|
96
|
+
|
|
97
|
+
# follow a catalog link to a full manifest
|
|
98
|
+
first = cat["manifests"][0]
|
|
99
|
+
man = json.loads(urllib.request.urlopen(
|
|
100
|
+
f"http://127.0.0.1:{port}{first['url']}").read())
|
|
101
|
+
assert man["service_id"] == first["service_id"]
|
|
102
|
+
assert man.get("asm_version") == "0.3"
|
|
103
|
+
finally:
|
|
104
|
+
srv.shutdown()
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Schema validation: every manifest under manifests/ and library/ must validate against
|
|
2
|
+
schema/asm-v0.3.schema.json. This test catches the class of regression where
|
|
3
|
+
new manifests use enum values not in the schema (e.g. an invalid
|
|
4
|
+
verification_status string).
|
|
5
|
+
|
|
6
|
+
Run:
|
|
7
|
+
pip install jsonschema pytest
|
|
8
|
+
python -m pytest scorer/test_manifests_schema.py -v
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
import jsonschema
|
|
20
|
+
except ImportError: # pragma: no cover
|
|
21
|
+
jsonschema = None
|
|
22
|
+
|
|
23
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
24
|
+
SCHEMA_PATH = ROOT / "schema" / "asm-v0.3.schema.json"
|
|
25
|
+
MANIFESTS_DIR = ROOT / "manifests"
|
|
26
|
+
LIBRARY_DIR = ROOT / "library"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture(scope="module")
|
|
30
|
+
def schema():
|
|
31
|
+
if jsonschema is None:
|
|
32
|
+
pytest.skip("jsonschema not installed; pip install jsonschema")
|
|
33
|
+
return json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def manifest_paths():
|
|
37
|
+
return sorted(MANIFESTS_DIR.glob("*.asm.json")) + sorted(LIBRARY_DIR.rglob("*.asm.json"))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_at_least_one_manifest():
|
|
41
|
+
paths = manifest_paths()
|
|
42
|
+
assert len(paths) > 0, "no manifests found under manifests/"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.mark.parametrize("manifest_path", manifest_paths(), ids=lambda p: p.name)
|
|
46
|
+
def test_manifest_validates(manifest_path: Path, schema):
|
|
47
|
+
document = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
48
|
+
jsonschema.validate(document, schema)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_no_extra_unknown_versions():
|
|
52
|
+
"""Sanity: every manifest declares asm_version 0.3."""
|
|
53
|
+
for path in manifest_paths():
|
|
54
|
+
d = json.loads(path.read_text(encoding="utf-8"))
|
|
55
|
+
assert d.get("asm_version") == "0.3", f"{path.name} declares asm_version={d.get('asm_version')}"
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Tests for ASM metadata embedded in MCP Registry server.json files."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
11
|
+
if str(ROOT) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(ROOT))
|
|
13
|
+
|
|
14
|
+
from mcp_server_json_asm import PUBLISHER_META_KEY, inspect_server_json, validate_manifest
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
EXAMPLES = ROOT / "examples" / "mcp-server-json"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_valid_server_json_with_inline_asm_passes():
|
|
21
|
+
result = inspect_server_json(EXAMPLES / "remote-with-asm.server.json")
|
|
22
|
+
|
|
23
|
+
assert result.has_asm is True
|
|
24
|
+
assert result.valid_asm is True
|
|
25
|
+
assert result.can_convert is True
|
|
26
|
+
assert result.asm_service_id == "example/remote-search@1.0"
|
|
27
|
+
assert result.asm_taxonomy == "tool.data.search"
|
|
28
|
+
assert result.missing_recommended_fields == []
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_missing_asm_block_is_warning_not_error(tmp_path):
|
|
32
|
+
server_json = tmp_path / "server.json"
|
|
33
|
+
server_json.write_text(
|
|
34
|
+
json.dumps({
|
|
35
|
+
"name": "io.asm.example/no-asm",
|
|
36
|
+
"description": "No ASM metadata yet",
|
|
37
|
+
"_meta": {
|
|
38
|
+
PUBLISHER_META_KEY: {
|
|
39
|
+
"maintainer": "example"
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
}),
|
|
43
|
+
encoding="utf-8",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
result = inspect_server_json(server_json)
|
|
47
|
+
|
|
48
|
+
assert result.has_asm is False
|
|
49
|
+
assert result.valid_asm is False
|
|
50
|
+
assert result.errors == []
|
|
51
|
+
assert result.warnings
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_malformed_asm_reports_schema_errors(tmp_path):
|
|
55
|
+
server_json = tmp_path / "server.json"
|
|
56
|
+
server_json.write_text(
|
|
57
|
+
json.dumps({
|
|
58
|
+
"name": "io.asm.example/bad-asm",
|
|
59
|
+
"_meta": {
|
|
60
|
+
PUBLISHER_META_KEY: {
|
|
61
|
+
"asm": {
|
|
62
|
+
"asm_version": "0.3",
|
|
63
|
+
"service_id": "example/bad@1.0"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
}),
|
|
68
|
+
encoding="utf-8",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
result = inspect_server_json(server_json)
|
|
72
|
+
|
|
73
|
+
assert result.has_asm is True
|
|
74
|
+
assert result.valid_asm is False
|
|
75
|
+
assert result.can_convert is False
|
|
76
|
+
assert any("taxonomy" in error for error in result.errors)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_extracted_manifest_validates_against_schema():
|
|
80
|
+
result = inspect_server_json(EXAMPLES / "package-with-asm.server.json")
|
|
81
|
+
|
|
82
|
+
assert result.asm_manifest is not None
|
|
83
|
+
assert validate_manifest(result.asm_manifest) == []
|
|
84
|
+
assert result.asm_manifest["service_id"] == "example/vector-db@1.0"
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from jsonschema import Draft202012Validator
|
|
8
|
+
|
|
9
|
+
from asm_cli import main as asm_main
|
|
10
|
+
from openrouter_adapter import (
|
|
11
|
+
load_openrouter_manifests,
|
|
12
|
+
openrouter_models_to_manifests,
|
|
13
|
+
)
|
|
14
|
+
from scorer import parse_manifest
|
|
15
|
+
from scorer import Preferences, ServiceVector, score_topsis
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
SAMPLE_MODELS = {
|
|
19
|
+
"data": [
|
|
20
|
+
{
|
|
21
|
+
"id": "openai/gpt-test",
|
|
22
|
+
"name": "OpenAI: GPT Test",
|
|
23
|
+
"context_length": 128000,
|
|
24
|
+
"pricing": {"prompt": "0.00000015", "completion": "0.0000006"},
|
|
25
|
+
"top_provider": {"context_length": 128000, "max_completion_tokens": 16000},
|
|
26
|
+
"architecture": {
|
|
27
|
+
"input_modalities": ["text", "image"],
|
|
28
|
+
"output_modalities": ["text"],
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"id": "cheap/free-model:free",
|
|
33
|
+
"name": "Cheap: Free Model",
|
|
34
|
+
"context_length": 8192,
|
|
35
|
+
"pricing": {"prompt": "0", "completion": "0"},
|
|
36
|
+
"architecture": {
|
|
37
|
+
"input_modalities": ["text"],
|
|
38
|
+
"output_modalities": ["text"],
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"id": "router/special",
|
|
43
|
+
"name": "Router: Special",
|
|
44
|
+
"pricing": {"prompt": "-1", "completion": "-1"},
|
|
45
|
+
"architecture": {
|
|
46
|
+
"input_modalities": ["text"],
|
|
47
|
+
"output_modalities": ["text"],
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
SAMPLE_RANKINGS = {
|
|
55
|
+
"generated_at": "2026-05-04T00:00:00Z",
|
|
56
|
+
"source": "fixture",
|
|
57
|
+
"n_models": 2,
|
|
58
|
+
"models": [
|
|
59
|
+
{
|
|
60
|
+
"permaslug": "openai/gpt-test-20260501",
|
|
61
|
+
"slug": "openai/gpt-test",
|
|
62
|
+
"author": "openai",
|
|
63
|
+
"prompt_tokens": 1000,
|
|
64
|
+
"completion_tokens": 500,
|
|
65
|
+
"total_tokens": 1500,
|
|
66
|
+
"count": 10,
|
|
67
|
+
"rank_by_prompt_tokens": 1,
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"permaslug": "other/model-20260501",
|
|
71
|
+
"slug": "other/model",
|
|
72
|
+
"author": "other",
|
|
73
|
+
"prompt_tokens": 100,
|
|
74
|
+
"completion_tokens": 50,
|
|
75
|
+
"total_tokens": 150,
|
|
76
|
+
"count": 2,
|
|
77
|
+
"rank_by_prompt_tokens": 2,
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_openrouter_models_to_ephemeral_manifests():
|
|
84
|
+
manifests = openrouter_models_to_manifests(
|
|
85
|
+
SAMPLE_MODELS["data"],
|
|
86
|
+
models_source="https://openrouter.ai/api/v1/models",
|
|
87
|
+
retrieved_at="2026-05-10T00:00:00Z",
|
|
88
|
+
ranking_by_slug={"openai/gpt-test": SAMPLE_RANKINGS["models"][0]},
|
|
89
|
+
ranking_count=2,
|
|
90
|
+
ranking_generated_at=SAMPLE_RANKINGS["generated_at"],
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
assert len(manifests) == 2
|
|
94
|
+
first = manifests[0]
|
|
95
|
+
schema = json.loads((Path(__file__).resolve().parent.parent / "schema" / "asm-v0.3.schema.json").read_text(encoding="utf-8"))
|
|
96
|
+
validator = Draft202012Validator(schema)
|
|
97
|
+
for manifest in manifests:
|
|
98
|
+
assert list(validator.iter_errors(manifest)) == []
|
|
99
|
+
|
|
100
|
+
assert first["service_id"] == "openrouter/openai/gpt-test@current"
|
|
101
|
+
assert first["taxonomy"] == "ai.llm.chat"
|
|
102
|
+
assert first["ttl"] == 300
|
|
103
|
+
assert first["provenance"]["verification_status"] == "self_reported"
|
|
104
|
+
assert "usage signal" in first["provenance"]["notes"]
|
|
105
|
+
assert first["pricing"]["billing_dimensions"][0]["cost_per_unit"] == pytest.approx(0.15)
|
|
106
|
+
assert first["pricing"]["billing_dimensions"][1]["cost_per_unit"] == pytest.approx(0.6)
|
|
107
|
+
assert first["quality"]["metrics"][0]["name"] == "openrouter_usage_signal"
|
|
108
|
+
assert first["quality"]["metrics"][0]["score"] == pytest.approx(1.0)
|
|
109
|
+
|
|
110
|
+
vector = parse_manifest(first, io_ratio=0.5)
|
|
111
|
+
assert vector.cost_per_unit == pytest.approx(0.375 / 1_000_000)
|
|
112
|
+
assert vector.quality_score == pytest.approx(1.0)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_load_openrouter_manifests_from_cached_json(tmp_path: Path):
|
|
116
|
+
models_path = tmp_path / "models.json"
|
|
117
|
+
rankings_path = tmp_path / "rankings.json"
|
|
118
|
+
models_path.write_text(json.dumps(SAMPLE_MODELS), encoding="utf-8")
|
|
119
|
+
rankings_path.write_text(json.dumps(SAMPLE_RANKINGS), encoding="utf-8")
|
|
120
|
+
|
|
121
|
+
manifests, metadata = load_openrouter_manifests(
|
|
122
|
+
models_json=models_path,
|
|
123
|
+
rankings_json=rankings_path,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
assert metadata["n_models"] == 3
|
|
127
|
+
assert metadata["n_manifests"] == 2
|
|
128
|
+
assert metadata["ranking_snapshot"] == "2026-05-04T00:00:00Z"
|
|
129
|
+
assert manifests[1]["quality"]["metrics"][0]["score"] == pytest.approx(0.5)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_cli_openrouter_source_with_cached_json(tmp_path: Path, capsys):
|
|
133
|
+
models_path = tmp_path / "models.json"
|
|
134
|
+
rankings_path = tmp_path / "rankings.json"
|
|
135
|
+
models_path.write_text(json.dumps(SAMPLE_MODELS), encoding="utf-8")
|
|
136
|
+
rankings_path.write_text(json.dumps(SAMPLE_RANKINGS), encoding="utf-8")
|
|
137
|
+
|
|
138
|
+
code = asm_main([
|
|
139
|
+
"score",
|
|
140
|
+
"--source",
|
|
141
|
+
"openrouter",
|
|
142
|
+
"--openrouter-models-json",
|
|
143
|
+
str(models_path),
|
|
144
|
+
"--openrouter-rankings-json",
|
|
145
|
+
str(rankings_path),
|
|
146
|
+
"cheap LLM under $1 per 1M tokens under 1s",
|
|
147
|
+
])
|
|
148
|
+
output = capsys.readouterr().out
|
|
149
|
+
|
|
150
|
+
assert code == 0
|
|
151
|
+
assert "OpenRouter ephemeral manifests" in output
|
|
152
|
+
assert "ignored latency hard constraint" in output
|
|
153
|
+
assert "representative cost <= $1.0000/1M blended tokens" in output
|
|
154
|
+
assert "Selected:" in output
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_cli_openrouter_subcommand_with_cached_json(tmp_path: Path, capsys):
|
|
158
|
+
models_path = tmp_path / "models.json"
|
|
159
|
+
rankings_path = tmp_path / "rankings.json"
|
|
160
|
+
models_path.write_text(json.dumps(SAMPLE_MODELS), encoding="utf-8")
|
|
161
|
+
rankings_path.write_text(json.dumps(SAMPLE_RANKINGS), encoding="utf-8")
|
|
162
|
+
|
|
163
|
+
code = asm_main([
|
|
164
|
+
"openrouter",
|
|
165
|
+
"--openrouter-models-json",
|
|
166
|
+
str(models_path),
|
|
167
|
+
"--openrouter-rankings-json",
|
|
168
|
+
str(rankings_path),
|
|
169
|
+
"cheap coding model under $1 per 1M tokens",
|
|
170
|
+
])
|
|
171
|
+
output = capsys.readouterr().out
|
|
172
|
+
|
|
173
|
+
assert code == 0
|
|
174
|
+
assert "OpenRouter ephemeral manifests" in output
|
|
175
|
+
assert "Model: cheap/free-model:free" in output
|
|
176
|
+
assert "Selected:" in output
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_cli_openrouter_json_format(tmp_path: Path, capsys):
|
|
180
|
+
models_path = tmp_path / "models.json"
|
|
181
|
+
rankings_path = tmp_path / "rankings.json"
|
|
182
|
+
models_path.write_text(json.dumps(SAMPLE_MODELS), encoding="utf-8")
|
|
183
|
+
rankings_path.write_text(json.dumps(SAMPLE_RANKINGS), encoding="utf-8")
|
|
184
|
+
|
|
185
|
+
code = asm_main([
|
|
186
|
+
"openrouter",
|
|
187
|
+
"--format",
|
|
188
|
+
"json",
|
|
189
|
+
"--openrouter-models-json",
|
|
190
|
+
str(models_path),
|
|
191
|
+
"--openrouter-rankings-json",
|
|
192
|
+
str(rankings_path),
|
|
193
|
+
"cheap coding model under $1 per 1M tokens",
|
|
194
|
+
])
|
|
195
|
+
payload = json.loads(capsys.readouterr().out)
|
|
196
|
+
|
|
197
|
+
assert code == 0
|
|
198
|
+
assert payload["selected"]["model"] == "cheap/free-model:free"
|
|
199
|
+
assert payload["ranked"][0]["cost_per_1m_blended_tokens"] == 0
|
|
200
|
+
assert payload["source"]["n_manifests"] == 2
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_cli_openrouter_route_litellm_format(tmp_path: Path, capsys):
|
|
204
|
+
models_path = tmp_path / "models.json"
|
|
205
|
+
rankings_path = tmp_path / "rankings.json"
|
|
206
|
+
models_path.write_text(json.dumps(SAMPLE_MODELS), encoding="utf-8")
|
|
207
|
+
rankings_path.write_text(json.dumps(SAMPLE_RANKINGS), encoding="utf-8")
|
|
208
|
+
|
|
209
|
+
code = asm_main([
|
|
210
|
+
"openrouter",
|
|
211
|
+
"route",
|
|
212
|
+
"--format",
|
|
213
|
+
"litellm",
|
|
214
|
+
"--openrouter-models-json",
|
|
215
|
+
str(models_path),
|
|
216
|
+
"--openrouter-rankings-json",
|
|
217
|
+
str(rankings_path),
|
|
218
|
+
"cheap coding model under $1 per 1M tokens",
|
|
219
|
+
])
|
|
220
|
+
output = capsys.readouterr().out
|
|
221
|
+
|
|
222
|
+
assert code == 0
|
|
223
|
+
assert "model_list:" in output
|
|
224
|
+
assert "model: openrouter/cheap/free-model:free" in output
|
|
225
|
+
assert "asm_score:" in output
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_topsis_handles_unknown_latency_without_nan():
|
|
229
|
+
services = [
|
|
230
|
+
ServiceVector("a", "A", "ai.llm.chat", 1.0, 0.5, float("inf"), 0.5),
|
|
231
|
+
ServiceVector("b", "B", "ai.llm.chat", 2.0, 0.8, float("inf"), 0.5),
|
|
232
|
+
]
|
|
233
|
+
results = score_topsis(services, Preferences(cost=0.4, quality=0.3, speed=0.2, reliability=0.1))
|
|
234
|
+
|
|
235
|
+
assert len(results) == 2
|
|
236
|
+
for result in results:
|
|
237
|
+
assert result.total_score == result.total_score
|
|
238
|
+
assert result.breakdown["speed"] == 1.0
|