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 ADDED
@@ -0,0 +1,626 @@
1
+ #!/usr/bin/env python3
2
+ """Small public CLI for trying ASM from a checkout or editable install."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import re
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ from openrouter_adapter import load_openrouter_manifests
13
+ from scorer import Constraints, Preferences, filter_services, load_manifests, parse_manifest, score_topsis
14
+
15
+
16
+ ROOT = Path(__file__).resolve().parent
17
+ DEFAULT_MANIFEST_DIR = ROOT / "manifests"
18
+
19
+
20
+ TAXONOMY_HINTS: list[tuple[str, tuple[str, ...]]] = [
21
+ ("ai.audio.tts", ("tts", "text to speech", "voiceover", "voice over", "voice", "speech")),
22
+ ("ai.audio.stt", ("stt", "speech to text", "transcription", "transcribe")),
23
+ ("ai.llm.chat", ("llm", "chat", "model", "reasoning", "assistant")),
24
+ ("ai.vision.image_generation", ("image", "picture", "illustration", "generate image")),
25
+ ("ai.video.generation", ("video", "clip", "movie")),
26
+ ("tool.data.search", ("search", "web search", "research")),
27
+ ("tool.communication.email", ("email", "mail")),
28
+ ]
29
+
30
+
31
+ def infer_taxonomy(query: str) -> str | None:
32
+ q = query.lower()
33
+ for taxonomy, hints in TAXONOMY_HINTS:
34
+ if any(h in q for h in hints):
35
+ return taxonomy
36
+ return None
37
+
38
+
39
+ def infer_constraints(query: str, taxonomy: str | None) -> Constraints:
40
+ q = query.lower()
41
+ max_latency_s = None
42
+
43
+ match = re.search(r"(?:under|below|less than|<=|<)\s*(\d+(?:\.\d+)?)\s*(ms|s|sec|second|seconds)", q)
44
+ if match:
45
+ value = float(match.group(1))
46
+ unit = match.group(2)
47
+ max_latency_s = value / 1000 if unit == "ms" else value
48
+
49
+ min_uptime = 0.99 if any(word in q for word in ("reliable", "reliability", "uptime")) else None
50
+ max_cost = None
51
+ cost_match = re.search(
52
+ r"(?:under|below|less than|<=|<)\s*\$?\s*(\d+(?:\.\d+)?)\s*(?:/|per)?\s*(?:1m|1 m|million|m)\s*(?:tokens?)?",
53
+ q,
54
+ )
55
+ if cost_match:
56
+ max_cost = float(cost_match.group(1)) / 1_000_000
57
+
58
+ return Constraints(required_taxonomy=taxonomy, max_latency_s=max_latency_s, min_uptime=min_uptime, max_cost=max_cost)
59
+
60
+
61
+ def infer_preferences(query: str) -> Preferences:
62
+ q = query.lower()
63
+ weights = {
64
+ "cost": 0.30,
65
+ "quality": 0.30,
66
+ "speed": 0.20,
67
+ "reliability": 0.20,
68
+ }
69
+ if any(word in q for word in ("cheap", "cheapest", "low cost", "budget")):
70
+ weights.update(cost=0.50, quality=0.20, speed=0.15, reliability=0.15)
71
+ if any(word in q for word in ("best", "highest quality", "quality", "accurate")):
72
+ weights.update(cost=0.15, quality=0.55, speed=0.15, reliability=0.15)
73
+ if any(word in q for word in ("fast", "latency", "under", "below", "low latency")):
74
+ weights["speed"] += 0.10
75
+ if any(word in q for word in ("reliable", "uptime", "stable")):
76
+ weights["reliability"] += 0.10
77
+
78
+ total = sum(weights.values())
79
+ normalized = {key: value / total for key, value in weights.items()}
80
+ return Preferences(**normalized)
81
+
82
+
83
+ def rejection_reason(service, constraints: Constraints) -> str | None:
84
+ if constraints.required_taxonomy and not service.taxonomy.startswith(constraints.required_taxonomy):
85
+ return f"taxonomy {service.taxonomy} does not match {constraints.required_taxonomy}"
86
+ if constraints.max_latency_s is not None and service.latency_seconds > constraints.max_latency_s:
87
+ return f"latency {service.latency_seconds:.2f}s > max {constraints.max_latency_s:.2f}s"
88
+ if constraints.min_uptime is not None and service.uptime < constraints.min_uptime:
89
+ return f"uptime {service.uptime:.3f} < min {constraints.min_uptime:.3f}"
90
+ if constraints.min_quality is not None and service.quality_score < constraints.min_quality:
91
+ return f"quality {service.quality_score:.3f} < min {constraints.min_quality:.3f}"
92
+ if constraints.max_cost is not None and service.cost_per_unit > constraints.max_cost:
93
+ return f"cost {format_cost(service.cost_per_unit, service.taxonomy)} > max {format_cost(constraints.max_cost, service.taxonomy)}"
94
+ return None
95
+
96
+
97
+ _QUALITY_TAGS = {
98
+ "lmarena_elo": "Elo",
99
+ "quality_unknown": "no-bench",
100
+ "openrouter_usage_signal": "usage",
101
+ }
102
+
103
+
104
+ def _quality_source_map(manifests: list[dict]) -> dict:
105
+ out = {}
106
+ for m in manifests:
107
+ mets = (m.get("quality") or {}).get("metrics") or []
108
+ name = mets[0].get("name") if mets else None
109
+ out[m.get("service_id")] = _QUALITY_TAGS.get(name, "")
110
+ return out
111
+
112
+
113
+ def _format_ranked_row(item, q_source: dict, hide_sla: bool) -> str:
114
+ tag = q_source.get(getattr(item.service, "service_id", None), "")
115
+ qtag = f" {tag}" if tag else ""
116
+ row = (
117
+ f"{item.rank}. {item.service.display_name} "
118
+ f"(score={item.total_score:.4f}, "
119
+ f"cost={format_cost(item.service.cost_per_unit, item.service.taxonomy)}, "
120
+ f"quality={item.service.quality_score:.3f}{qtag}"
121
+ )
122
+ if not hide_sla:
123
+ row += f", latency={format_latency(item.service.latency_seconds)}, uptime={item.service.uptime:.3f}"
124
+ return row + ")"
125
+
126
+
127
+ def _arena_category_for(query: str) -> str:
128
+ return "coding" if re.search(r"\bcod(?:e|ing)\b|program", query.lower()) else "overall"
129
+
130
+
131
+ def cmd_score(args: argparse.Namespace) -> int:
132
+ source_metadata = None
133
+ openrouter_latency_ignored = False
134
+ openrouter_uptime_ignored = False
135
+ if args.source == "openrouter":
136
+ manifests, source_metadata = load_openrouter_manifests(
137
+ models_json=args.openrouter_models_json,
138
+ rankings_json=args.openrouter_rankings_json,
139
+ arena_category=_arena_category_for(args.query),
140
+ timeout=args.openrouter_timeout,
141
+ )
142
+ taxonomy = args.taxonomy or "ai.llm.chat"
143
+ manifest_dir = None
144
+ else:
145
+ manifest_dir = Path(args.manifests)
146
+ manifests = load_manifests(manifest_dir)
147
+ taxonomy = args.taxonomy or infer_taxonomy(args.query)
148
+
149
+ constraints = infer_constraints(args.query, taxonomy)
150
+ preferences = infer_preferences(args.query)
151
+ if args.source == "openrouter" and constraints.max_latency_s is not None and not args.strict_latency:
152
+ constraints.max_latency_s = None
153
+ openrouter_latency_ignored = True
154
+ if args.source == "openrouter" and constraints.min_uptime is not None:
155
+ constraints.min_uptime = None
156
+ openrouter_uptime_ignored = True
157
+
158
+ candidate_manifests = [
159
+ m for m in manifests
160
+ if not taxonomy or str(m.get("taxonomy", "")).startswith(taxonomy)
161
+ ]
162
+ if not candidate_manifests:
163
+ location = source_metadata["source"] if source_metadata else str(manifest_dir)
164
+ print(f"No candidate manifests found for taxonomy={taxonomy or 'any'} in {location}")
165
+ return 1
166
+
167
+ services = [parse_manifest(m, io_ratio=preferences.io_ratio) for m in candidate_manifests]
168
+ selected = filter_services(services, constraints)
169
+ ranked = score_topsis(selected, preferences)
170
+
171
+ print(f"Query: {args.query}")
172
+ print(f"Taxonomy: {taxonomy or 'any'}")
173
+ if source_metadata:
174
+ print(
175
+ "Source: OpenRouter ephemeral manifests "
176
+ f"({source_metadata['n_manifests']} scoreable / {source_metadata['n_models']} models, "
177
+ f"retrieved_at={source_metadata['retrieved_at']})"
178
+ )
179
+ if source_metadata.get("arena_elo_snapshot"):
180
+ print(
181
+ f"Quality: LMArena Elo (snapshot {source_metadata['arena_elo_snapshot']}), "
182
+ f"benchmark-backed for {source_metadata.get('arena_elo_matched', 0)}/{source_metadata['n_manifests']} models; "
183
+ "the rest scored neutral (quality unknown)."
184
+ )
185
+ if source_metadata.get("ranking_snapshot"):
186
+ print(f"Usage signal (secondary): cached OpenRouter ranking snapshot {source_metadata['ranking_snapshot']}")
187
+ print("Caveat: Elo is human-preference quality; OpenRouter usage is a revealed-preference signal, not quality.")
188
+ if openrouter_latency_ignored:
189
+ print("Warning: OpenRouter /api/v1/models does not expose latency; ignored latency hard constraint.")
190
+ if openrouter_uptime_ignored:
191
+ print("Warning: OpenRouter /api/v1/models does not expose uptime; ignored uptime hard constraint.")
192
+ print(
193
+ "Preferences: "
194
+ f"cost={preferences.cost:.2f}, quality={preferences.quality:.2f}, "
195
+ f"speed={preferences.speed:.2f}, reliability={preferences.reliability:.2f}"
196
+ )
197
+ if constraints.max_latency_s is not None or constraints.min_uptime is not None or constraints.max_cost is not None:
198
+ parts = []
199
+ if constraints.max_latency_s is not None:
200
+ parts.append(f"latency <= {constraints.max_latency_s:.2f}s")
201
+ if constraints.min_uptime is not None:
202
+ parts.append(f"uptime >= {constraints.min_uptime:.3f}")
203
+ if constraints.max_cost is not None:
204
+ parts.append(f"representative cost <= {format_cost(constraints.max_cost, taxonomy)}")
205
+ print(f"Hard constraints: {', '.join(parts)}")
206
+
207
+ if not ranked:
208
+ print("\nNo service satisfies the hard constraints.")
209
+ else:
210
+ winner = ranked[0]
211
+ print(f"\nSelected: {winner.service.display_name}")
212
+ print(f"Reason: {winner.reasoning}")
213
+ print("\nRanked services:")
214
+ q_source = _quality_source_map(candidate_manifests)
215
+ hide_sla = args.source == "openrouter"
216
+ for item in ranked[: args.limit]:
217
+ print(_format_ranked_row(item, q_source, hide_sla))
218
+ if args.source == "openrouter":
219
+ print(f'\nTip: turn this into a router config -> asm openrouter route --format litellm "{args.query}"')
220
+
221
+ rejected = []
222
+ for service in services:
223
+ reason = rejection_reason(service, constraints)
224
+ if reason:
225
+ rejected.append((service.display_name, reason))
226
+
227
+ if rejected:
228
+ print("\nRejected by hard constraints:")
229
+ for name, reason in rejected[: args.limit]:
230
+ print(f"- {name}: {reason}")
231
+ else:
232
+ print("\nRejected by hard constraints: none")
233
+
234
+ return 0 if ranked else 2
235
+
236
+
237
+ def cmd_openrouter(args: argparse.Namespace) -> int:
238
+ query_parts = list(args.query)
239
+ mode = "score"
240
+ if query_parts and query_parts[0] == "route":
241
+ mode = "route"
242
+ query_parts = query_parts[1:]
243
+ query = " ".join(query_parts).strip()
244
+ if not query:
245
+ print('OpenRouter query is required. Example: asm openrouter "cheap coding model under $0.50/1M tokens"')
246
+ return 1
247
+
248
+ manifests, source_metadata = load_openrouter_manifests(
249
+ models_json=args.openrouter_models_json,
250
+ rankings_json=args.openrouter_rankings_json,
251
+ arena_category=_arena_category_for(query),
252
+ timeout=args.openrouter_timeout,
253
+ )
254
+ preferences = infer_preferences(query)
255
+ constraints = infer_constraints(query, "ai.llm.chat")
256
+ latency_ignored = False
257
+ uptime_ignored = False
258
+ if constraints.max_latency_s is not None and not args.strict_latency:
259
+ constraints.max_latency_s = None
260
+ latency_ignored = True
261
+ if constraints.min_uptime is not None:
262
+ constraints.min_uptime = None
263
+ uptime_ignored = True
264
+
265
+ services = [parse_manifest(m, io_ratio=preferences.io_ratio) for m in manifests]
266
+ selected = filter_services(services, constraints)
267
+ ranked = score_topsis(selected, preferences)
268
+
269
+ if args.format == "json":
270
+ print(json.dumps(
271
+ _openrouter_json_payload(query, ranked, services, constraints, preferences, source_metadata, latency_ignored, args.limit),
272
+ indent=2,
273
+ ))
274
+ return 0 if ranked else 2
275
+
276
+ output_format = "litellm" if mode == "route" and args.format == "text" else args.format
277
+ if mode == "route" or output_format != "text":
278
+ if not ranked:
279
+ print("No OpenRouter model satisfies the hard constraints.")
280
+ return 2
281
+ print(_format_route_config(output_format, ranked[: args.limit], query))
282
+ return 0
283
+
284
+ _print_selection(
285
+ query=query,
286
+ taxonomy="ai.llm.chat",
287
+ source_metadata=source_metadata,
288
+ preferences=preferences,
289
+ constraints=constraints,
290
+ ranked=ranked,
291
+ services=services,
292
+ limit=args.limit,
293
+ openrouter_latency_ignored=latency_ignored,
294
+ openrouter_uptime_ignored=uptime_ignored,
295
+ quality_tags=_quality_source_map(manifests),
296
+ )
297
+ return 0 if ranked else 2
298
+
299
+
300
+ def _print_selection(
301
+ *,
302
+ query: str,
303
+ taxonomy: str | None,
304
+ source_metadata: dict | None,
305
+ preferences: Preferences,
306
+ constraints: Constraints,
307
+ ranked: list,
308
+ services: list,
309
+ limit: int,
310
+ openrouter_latency_ignored: bool = False,
311
+ openrouter_uptime_ignored: bool = False,
312
+ quality_tags: dict | None = None,
313
+ ) -> None:
314
+ print(f"Query: {query}")
315
+ print(f"Taxonomy: {taxonomy or 'any'}")
316
+ if source_metadata:
317
+ print(
318
+ "Source: OpenRouter ephemeral manifests "
319
+ f"({source_metadata['n_manifests']} scoreable / {source_metadata['n_models']} models, "
320
+ f"retrieved_at={source_metadata['retrieved_at']})"
321
+ )
322
+ if source_metadata.get("arena_elo_snapshot"):
323
+ print(
324
+ f"Quality: LMArena Elo (snapshot {source_metadata['arena_elo_snapshot']}), "
325
+ f"benchmark-backed for {source_metadata.get('arena_elo_matched', 0)}/{source_metadata['n_manifests']} models; "
326
+ "the rest scored neutral (quality unknown)."
327
+ )
328
+ if source_metadata.get("ranking_snapshot"):
329
+ print(f"Usage signal (secondary): cached OpenRouter ranking snapshot {source_metadata['ranking_snapshot']}")
330
+ print("Caveat: Elo is human-preference quality; OpenRouter usage is a revealed-preference signal, not quality.")
331
+ if openrouter_latency_ignored:
332
+ print("Warning: OpenRouter /api/v1/models does not expose latency; ignored latency hard constraint.")
333
+ if openrouter_uptime_ignored:
334
+ print("Warning: OpenRouter /api/v1/models does not expose uptime; ignored uptime hard constraint.")
335
+ print(
336
+ "Preferences: "
337
+ f"cost={preferences.cost:.2f}, quality={preferences.quality:.2f}, "
338
+ f"speed={preferences.speed:.2f}, reliability={preferences.reliability:.2f}"
339
+ )
340
+ if constraints.max_latency_s is not None or constraints.min_uptime is not None or constraints.max_cost is not None:
341
+ parts = []
342
+ if constraints.max_latency_s is not None:
343
+ parts.append(f"latency <= {constraints.max_latency_s:.2f}s")
344
+ if constraints.min_uptime is not None:
345
+ parts.append(f"uptime >= {constraints.min_uptime:.3f}")
346
+ if constraints.max_cost is not None:
347
+ parts.append(f"representative cost <= {format_cost(constraints.max_cost, taxonomy)}")
348
+ print(f"Hard constraints: {', '.join(parts)}")
349
+
350
+ if not ranked:
351
+ print("\nNo service satisfies the hard constraints.")
352
+ else:
353
+ winner = ranked[0]
354
+ print(f"\nSelected: {winner.service.display_name}")
355
+ print(f"Model: {_openrouter_model_id(winner.service) or winner.service.service_id}")
356
+ print(f"Reason: {winner.reasoning}")
357
+ print("\nRanked services:")
358
+ q_source = quality_tags or {}
359
+ for item in ranked[:limit]:
360
+ print(_format_ranked_row(item, q_source, hide_sla=True))
361
+ print(f'\nTip: turn this into a router config -> asm openrouter route --format litellm "{query}"')
362
+
363
+ rejected = []
364
+ for service in services:
365
+ reason = rejection_reason(service, constraints)
366
+ if reason:
367
+ rejected.append((service.display_name, reason))
368
+
369
+ if rejected:
370
+ print("\nRejected by hard constraints:")
371
+ for name, reason in rejected[:limit]:
372
+ print(f"- {name}: {reason}")
373
+ else:
374
+ print("\nRejected by hard constraints: none")
375
+
376
+
377
+ def format_cost(cost_per_unit: float, taxonomy: str | None) -> str:
378
+ if taxonomy and taxonomy.startswith("ai.llm"):
379
+ return f"${cost_per_unit * 1_000_000:.4f}/1M blended tokens"
380
+ return f"${cost_per_unit:.8f}/unit"
381
+
382
+
383
+ def format_latency(latency_seconds: float) -> str:
384
+ if latency_seconds == float("inf"):
385
+ return "unknown"
386
+ return f"{latency_seconds:.2f}s"
387
+
388
+
389
+ def _openrouter_model_id(service) -> str | None:
390
+ prefix = "openrouter/"
391
+ suffix = "@current"
392
+ service_id = service.service_id
393
+ if service_id.startswith(prefix) and service_id.endswith(suffix):
394
+ return service_id[len(prefix):-len(suffix)]
395
+ return None
396
+
397
+
398
+ def _openrouter_json_payload(
399
+ query: str,
400
+ ranked: list,
401
+ services: list,
402
+ constraints: Constraints,
403
+ preferences: Preferences,
404
+ source_metadata: dict,
405
+ latency_ignored: bool,
406
+ limit: int,
407
+ ) -> dict:
408
+ rejected = []
409
+ for service in services:
410
+ reason = rejection_reason(service, constraints)
411
+ if reason:
412
+ rejected.append({"service": service.display_name, "model": _openrouter_model_id(service), "reason": reason})
413
+
414
+ return {
415
+ "query": query,
416
+ "source": source_metadata,
417
+ "caveat": "OpenRouter usage is a revealed-preference signal, not benchmark quality.",
418
+ "warnings": ["OpenRouter /api/v1/models does not expose latency; latency hard constraint ignored."] if latency_ignored else [],
419
+ "preferences": {
420
+ "cost": preferences.cost,
421
+ "quality": preferences.quality,
422
+ "speed": preferences.speed,
423
+ "reliability": preferences.reliability,
424
+ "io_ratio": preferences.io_ratio,
425
+ },
426
+ "selected": _scored_to_dict(ranked[0]) if ranked else None,
427
+ "ranked": [_scored_to_dict(item) for item in ranked[:limit]],
428
+ "rejected": rejected[:limit],
429
+ }
430
+
431
+
432
+ def _scored_to_dict(item) -> dict:
433
+ return {
434
+ "rank": item.rank,
435
+ "model": _openrouter_model_id(item.service),
436
+ "service_id": item.service.service_id,
437
+ "display_name": item.service.display_name,
438
+ "score": item.total_score,
439
+ "cost_per_1m_blended_tokens": round(item.service.cost_per_unit * 1_000_000, 6),
440
+ "quality": item.service.quality_score,
441
+ "latency_seconds": None if item.service.latency_seconds == float("inf") else item.service.latency_seconds,
442
+ "uptime": item.service.uptime,
443
+ "reason": item.reasoning,
444
+ }
445
+
446
+
447
+ def _format_route_config(fmt: str, ranked: list, query: str) -> str:
448
+ models = [(_openrouter_model_id(item.service) or item.service.service_id, item) for item in ranked]
449
+ if fmt == "litellm":
450
+ lines = [
451
+ "# Generated by ASM from OpenRouter value metadata.",
452
+ f"# Query: {query}",
453
+ "model_list:",
454
+ ]
455
+ for idx, (model_id, item) in enumerate(models, start=1):
456
+ alias = "asm-primary" if idx == 1 else f"asm-fallback-{idx - 1}"
457
+ lines.extend([
458
+ f" - model_name: {alias}",
459
+ " litellm_params:",
460
+ f" model: openrouter/{model_id}",
461
+ f" asm_score: {item.total_score:.4f}",
462
+ ])
463
+ lines.extend([
464
+ "router_settings:",
465
+ " routing_strategy: usage-based-routing",
466
+ ])
467
+ if len(models) > 1:
468
+ lines.append(" fallbacks:")
469
+ lines.append(" - asm-primary:")
470
+ for idx in range(1, len(models)):
471
+ lines.append(f" - asm-fallback-{idx}")
472
+ return "\n".join(lines)
473
+
474
+ if fmt == "vercel-ai-sdk":
475
+ primary = models[0][0]
476
+ fallbacks = [model_id for model_id, _ in models[1:]]
477
+ return "\n".join([
478
+ "// Generated by ASM from OpenRouter value metadata.",
479
+ f"// Query: {query}",
480
+ "import { openrouter } from '@openrouter/ai-sdk-provider';",
481
+ "",
482
+ f"export const model = openrouter('{primary}');",
483
+ f"export const fallbackModels = {json.dumps(fallbacks)}.map((id) => openrouter(id));",
484
+ ])
485
+
486
+ if fmt == "langchain":
487
+ primary = models[0][0]
488
+ fallbacks = [model_id for model_id, _ in models[1:]]
489
+ return "\n".join([
490
+ "# Generated by ASM from OpenRouter value metadata.",
491
+ f"# Query: {query}",
492
+ "import os",
493
+ "from langchain_openai import ChatOpenAI",
494
+ "",
495
+ "primary = ChatOpenAI(",
496
+ f" model=\"{primary}\",",
497
+ " base_url=\"https://openrouter.ai/api/v1\",",
498
+ " api_key=os.environ[\"OPENROUTER_API_KEY\"],",
499
+ ")",
500
+ f"fallback_model_ids = {json.dumps(fallbacks)}",
501
+ ])
502
+
503
+ raise ValueError(f"Unsupported route format: {fmt}")
504
+
505
+
506
+ def cmd_select(args: argparse.Namespace) -> int:
507
+ """Tool selection over the library/ manifests (the agent-tool-selection wedge)."""
508
+ from library_select import select # stdlib-only; lazy to keep CLI startup lean
509
+
510
+ result = select(
511
+ args.task,
512
+ taxonomy=args.taxonomy,
513
+ agent_reach=args.reach,
514
+ user_platform=args.platform,
515
+ required_functions=[f for f in (args.requires or "").split(",") if f],
516
+ require_approval_for=[s for s in (args.approval_for or "").split(",") if s],
517
+ require_agent_completable_setup=args.agent_setup_only,
518
+ )
519
+ if args.json:
520
+ print(json.dumps(result, indent=2, ensure_ascii=False))
521
+ return 0 if result["selected"] else 2
522
+
523
+ sel = result["selected"]
524
+ if not sel:
525
+ print("No eligible tool.")
526
+ for r in result["rejected"][: args.limit]:
527
+ print(f" - {r['service']}: {r['reason']}")
528
+ return 2
529
+ print(f"Selected: {sel['display_name']} ({sel['service_id']})")
530
+ print(f" cost=${sel['monthly_cost_usd']}/mo, interface={sel['interface']}, reach={sel['reach']}")
531
+ if sel.get("agent_completable_setup") is not None:
532
+ print(f" setup: agent_completable={sel['agent_completable_setup']}, requires={sel.get('setup_requires', [])}")
533
+ print(f" risk={result['risk_class']}, approval_required={result['approval_required']}, side_effects={result['side_effects']}")
534
+ print(f" reason: {result['reason']}")
535
+ for alt in result["alternatives"][: args.limit]:
536
+ print(f" alt: {alt['display_name']} (${alt['monthly_cost_usd']}/mo)")
537
+ if result["rejected"]:
538
+ print(" filtered out:")
539
+ for r in result["rejected"][: args.limit]:
540
+ print(f" - {r['service']}: {r['reason']}")
541
+ return 0
542
+
543
+
544
+ def build_parser() -> argparse.ArgumentParser:
545
+ parser = argparse.ArgumentParser(prog="asm", description="Agent Service Manifest CLI")
546
+ sub = parser.add_subparsers(dest="command", required=True)
547
+
548
+ sel = sub.add_parser("select", help="Pick a TOOL from the ASM library for an agent task")
549
+ sel.add_argument("task", help='Example: "find and book a refundable flight"')
550
+ sel.add_argument("--taxonomy", help="Scope candidates, e.g. tool.booking.travel")
551
+ sel.add_argument("--reach", default="cloud", choices=["cloud", "local_device"],
552
+ help="Where the agent runs (default: cloud)")
553
+ sel.add_argument("--platform", default="any", help="User platform: windows, macos, ios, android, web, any")
554
+ sel.add_argument("--requires", help="Comma-separated required functions, e.g. flight_search,flight_order_create")
555
+ sel.add_argument("--approval-for", dest="approval_for",
556
+ help="Comma-separated side-effects that force approval, e.g. financial_charge,sends_message")
557
+ sel.add_argument("--agent-setup-only", action="store_true",
558
+ help="Drop tools needing human-in-the-loop setup (paid signup, OAuth consent, approval)")
559
+ sel.add_argument("--json", action="store_true", help="Emit the structured decision as JSON")
560
+ sel.add_argument("--limit", type=int, default=5, help="Maximum alternatives/rejections to print")
561
+ sel.set_defaults(func=cmd_select)
562
+
563
+ score = sub.add_parser("score", help="Rank services for a natural-language service request")
564
+ score.add_argument("query", help='Example: "cheap reliable TTS under 1s"')
565
+ score.add_argument("--taxonomy", help="Override inferred taxonomy, e.g. ai.audio.tts")
566
+ score.add_argument("--manifests", default=str(DEFAULT_MANIFEST_DIR), help="Directory of .asm.json manifests")
567
+ score.add_argument("--source", choices=["local", "openrouter"], default="local",
568
+ help="Manifest source. 'openrouter' builds ephemeral manifests from OpenRouter model metadata.")
569
+ score.add_argument("--openrouter-models-json", help="Use a cached OpenRouter /api/v1/models JSON file")
570
+ score.add_argument("--openrouter-rankings-json", help="Use a cached OpenRouter rankings JSON file")
571
+ score.add_argument("--openrouter-timeout", type=int, default=20, help="Timeout for OpenRouter models API fetch")
572
+ score.add_argument("--strict-latency", action="store_true",
573
+ help="Do not ignore latency constraints for sources with unknown latency")
574
+ score.add_argument("--limit", type=int, default=5, help="Maximum ranked/rejected rows to print")
575
+ score.set_defaults(func=cmd_score)
576
+
577
+ openrouter = sub.add_parser("openrouter", help="Rank live OpenRouter models and optionally emit router configs")
578
+ openrouter.add_argument(
579
+ "query",
580
+ nargs="+",
581
+ help='Query, optionally prefixed with route. Example: asm openrouter "cheap coding model under $0.50/1M tokens"',
582
+ )
583
+ openrouter.add_argument("--format", choices=["text", "json", "litellm", "vercel-ai-sdk", "langchain"], default="text")
584
+ openrouter.add_argument("--openrouter-models-json", help="Use a cached OpenRouter /api/v1/models JSON file")
585
+ openrouter.add_argument("--openrouter-rankings-json", help="Use a cached OpenRouter rankings JSON file")
586
+ openrouter.add_argument("--openrouter-timeout", type=int, default=20, help="Timeout for OpenRouter models API fetch")
587
+ openrouter.add_argument("--strict-latency", action="store_true",
588
+ help="Do not ignore latency constraints for sources with unknown latency")
589
+ openrouter.add_argument("--limit", type=int, default=5, help="Maximum ranked models or route entries to print")
590
+ openrouter.set_defaults(func=cmd_openrouter)
591
+ return parser
592
+
593
+
594
+ def build_openrouter_parser() -> argparse.ArgumentParser:
595
+ parser = argparse.ArgumentParser(prog="asm openrouter", description="Rank live OpenRouter models")
596
+ parser.add_argument("--format", choices=["text", "json", "litellm", "vercel-ai-sdk", "langchain"], default="text")
597
+ parser.add_argument("--openrouter-models-json", help="Use a cached OpenRouter /api/v1/models JSON file")
598
+ parser.add_argument("--openrouter-rankings-json", help="Use a cached OpenRouter rankings JSON file")
599
+ parser.add_argument("--openrouter-timeout", type=int, default=20, help="Timeout for OpenRouter models API fetch")
600
+ parser.add_argument("--strict-latency", action="store_true",
601
+ help="Do not ignore latency constraints for sources with unknown latency")
602
+ parser.add_argument("--limit", type=int, default=5, help="Maximum ranked models or route entries to print")
603
+ parser.add_argument(
604
+ "query",
605
+ nargs="*",
606
+ help='Query, optionally prefixed with route. Example: asm openrouter route --format litellm "cheap coding model"',
607
+ )
608
+ return parser
609
+
610
+
611
+ def main(argv: list[str] | None = None) -> int:
612
+ if argv is None:
613
+ argv = sys.argv[1:]
614
+ if argv and argv[0] == "openrouter":
615
+ parser = build_openrouter_parser()
616
+ args, query_parts = parser.parse_known_args(argv[1:])
617
+ args.query = args.query + query_parts
618
+ return cmd_openrouter(args)
619
+
620
+ parser = build_parser()
621
+ args = parser.parse_args(argv)
622
+ return args.func(args)
623
+
624
+
625
+ if __name__ == "__main__":
626
+ raise SystemExit(main())