diff --git a/cli/commands/mcp.py b/cli/commands/mcp.py index 29acef9..06a7c05 100644 --- a/cli/commands/mcp.py +++ b/cli/commands/mcp.py @@ -1,7 +1,10 @@ """hl mcp — MCP server for AI agent tool discovery.""" from __future__ import annotations +import os import sys +import urllib.parse +import webbrowser from pathlib import Path import typer @@ -28,3 +31,30 @@ def mcp_serve( server = create_mcp_server() typer.echo(f"Starting MCP server (transport={transport}) ...") server.run(transport=transport) + + +@mcp_app.command("buy-more") +def mcp_buy_more( + account_id: str = typer.Option("", "--account-id", help="Nunchi account/workspace to top up."), + plan_id: str = typer.Option("", "--plan-id", help="Subscription plan context for the top-up page."), + credits: int = typer.Option(100, "--credits", min=1, help="Credit pack size to preselect."), + open_browser: bool = typer.Option(True, "--open/--no-open", help="Open the web-auth top-up page in a browser."), +): + """Open web-auth to buy more MCP subscription credits.""" + base = os.environ.get("VITE_PAIR_API_URL", "https://web-auth-opal.vercel.app").rstrip("/") + resolved_account_id = account_id or os.environ.get("NUNCHI_ACCOUNT_ID", "").strip() + resolved_plan_id = plan_id or os.environ.get("NUNCHI_PLAN_ID", "").strip() + query = { + "view": "agent-subscriptions", + "topUp": "credits", + "credits": str(credits), + } + if resolved_account_id: + query["accountId"] = resolved_account_id + if resolved_plan_id: + query["planId"] = resolved_plan_id + url = f"{base}/?{urllib.parse.urlencode(query)}" + typer.echo("Buy more MCP credits in web-auth:") + typer.echo(url) + if open_browser: + webbrowser.open(url) diff --git a/docs/MCP_PRICING_MEASUREMENTS.md b/docs/MCP_PRICING_MEASUREMENTS.md index 4c1b2a3..f56e539 100644 --- a/docs/MCP_PRICING_MEASUREMENTS.md +++ b/docs/MCP_PRICING_MEASUREMENTS.md @@ -9,6 +9,7 @@ Run: ```bash python3 scripts/pricing_measure.py --output tmp/pricing-measurement-local.json railway run --service hosted-trading-mcp --environment production -- python3 scripts/pricing_measure.py --output tmp/pricing-measurement-runner-env.json +python3 scripts/pricing_measure.py --job-profile maker --job-profile taker --job-profile hedge --railway-metrics --output tmp/pricing-job-profiles.json ``` Use `--openrouter-live` only when spending OpenRouter credits is intended. @@ -40,18 +41,48 @@ The pricing harness now emits the full Task 7 classification: - Safety-gated/fund-moving/wallet-write: 7 tools, or 6 if `wallet_auto` is excluded from the costable 26-tool surface because it is disabled on the hosted keyless runner. -- Recommended beta free cap: about 20 hosted MCP discovery/read calls before - subscription or upgrade nudges. +- Recommended beta free cap: about 20 hosted MCP discovery/read or + safety-gated execution calls before subscription or upgrade nudges. + `trade` and `funding_hedge_execute` remain safety-gated but are eligible to + spend this 20-call clone/local quota before paid access is required. + +## Job Profiles + +`pricing_measure.py` now emits deterministic monthly usage estimates for the +three Sam-facing workflow profiles: + +- `hedge`: 2 seats, about 8,700 MCP calls, 30 paid-compute calls, about $5 + inference, and about 93.5 credits; recommended placeholder plan is + `hosted-mcp-inference-growth`. +- `maker`: 5 seats, about 45,150 MCP calls, 150 paid-compute calls, BYO + inference, and about 459 credits; recommended placeholder plan is + `hosted-mcp-tools-team`. +- `taker`: 2 seats, about 5,500 MCP calls, 500 paid-compute calls, about $15 + inference, and about 95 credits; recommended placeholder plan is + `hosted-mcp-inference-growth`. + +Credit rates mirror web-auth: 0.01 credits per hosted MCP call, 0.05 credits per +paid-compute call, and 1 credit per $1 of Nunchi/OpenRouter inference spend. +The working OpenRouter starter plan is 100 credits/month; MCP-only tiers stay +cheaper because BYO inference spend is outside Nunchi billing. + +These are pricing simulations, not funded-fill proof. The funded maker/taker +fill path is still gated by wallet credentials and testnet funding. ## Economics Mode 1, hosted MCP tools: -- `C_seat` is not computed yet. Railway metrics expose CPU/memory/network, but not monthly billing cost. Set `RAILWAY_SHARED_RUNTIME_MONTHLY_USD` or pass `--runtime-monthly-usd` once billing data is available. +- `C_seat` is not computed yet. `--railway-metrics` captures Railway + CPU/memory/network/disk summaries, but Railway metrics are not monthly + billing cost. Set `RAILWAY_SHARED_RUNTIME_MONTHLY_USD` or pass + `--runtime-monthly-usd` once billing data is available. Mode 2, hosted MCP tools plus Nunchi/OpenRouter inference: -- No live OpenRouter spend was measured because `OPENROUTER_API_KEY` is not present in the runner env and `--openrouter-live` was not run. +- Live OpenRouter spend can be measured with `--openrouter-live`. The probe uses + `max_tokens=16` because current OpenRouter providers reject smaller completion + caps. - Current inference budgets remain inputs only: Starter `$10`, Growth `$50`, Team `$250`. - Anchor estimates from the Task 7 prompt: - `openai/gpt-4.1-mini` at about `$0.0002` per heartbeat gives about @@ -67,11 +98,13 @@ Mode 3, clone/local plus builder economics: - Formulaic builder-fee economics at the default `BUILDER_FEE_TENTHS_BPS=100` are `$100` per `$100,000` notional and `$1,000` per `$1,000,000` notional. - `trade` itself should remain free or low-friction from a pricing standpoint: it is safety-sensitive, not inference-heavy, and it is the path that can - produce builder-code/builder-fee economics. Gate it with confirmation, - builder-code validation, network consent, size limits, and signing context. + produce builder-code/builder-fee economics. It now spends the clone/local + 20-call free quota together with `funding_hedge_execute`, then requires paid + hosted MCP access after that allowance is exhausted. ## Blockers - Missing Railway monthly billing cost input for Mode 1 `C_seat`. -- Missing `OPENROUTER_API_KEY` for Mode 2 inference spend. +- Missing `OPENROUTER_API_KEY` only in environments where `--openrouter-live` + should run. - Missing funded-wallet/HL signing credentials for live fills and builder-fee realization. diff --git a/scripts/pricing_measure.py b/scripts/pricing_measure.py index de6f30c..700e1ad 100644 --- a/scripts/pricing_measure.py +++ b/scripts/pricing_measure.py @@ -10,6 +10,7 @@ import argparse import json import os +import shutil import subprocess import sys import time @@ -37,6 +38,36 @@ } FREE_TOOL_CALL_LIMIT_RECOMMENDATION = 20 +OPENROUTER_PROBE_MAX_TOKENS = 16 +CREDIT_RATES = { + "mcpCallCredits": 0.01, + "paidComputeCallCredits": 0.05, + "inferenceUsdCredits": 1.0, +} + +PLAN_LIMITS = { + "hosted-mcp-tools-starter": {"product": "hosted-mcp-tools", "seats": 5, "credits": 50, "mcpCalls": 2_000, "paidComputeCalls": 100, "inferenceUsd": 0.0}, + "hosted-mcp-tools-growth": {"product": "hosted-mcp-tools", "seats": 10, "credits": 150, "mcpCalls": 10_000, "paidComputeCalls": 500, "inferenceUsd": 0.0}, + "hosted-mcp-tools-team": {"product": "hosted-mcp-tools", "seats": 50, "credits": 500, "mcpCalls": 50_000, "paidComputeCalls": 2_500, "inferenceUsd": 0.0}, + "hosted-mcp-inference-starter": {"product": "hosted-mcp-tools-inference", "seats": 5, "credits": 100, "mcpCalls": 2_000, "paidComputeCalls": 100, "inferenceUsd": 10.0}, + "hosted-mcp-inference-growth": {"product": "hosted-mcp-tools-inference", "seats": 10, "credits": 300, "mcpCalls": 10_000, "paidComputeCalls": 500, "inferenceUsd": 50.0}, + "hosted-mcp-inference-team": {"product": "hosted-mcp-tools-inference", "seats": 50, "credits": 1_000, "mcpCalls": 50_000, "paidComputeCalls": 2_500, "inferenceUsd": 250.0}, +} + +JOB_PROFILES = { + "hedge": { + "description": "15-minute status/account reads, hedge proposal/backtest, and occasional confirmed hedge execution.", + "usage": {"seats": 2, "freeToolCalls": 8_640, "safetyGatedCalls": 30, "paidComputeCalls": 30, "inferenceUsd": 5.0}, + }, + "maker": { + "description": "Continuous quoting desk with frequent status/account reads and safety-gated trade submissions.", + "usage": {"seats": 5, "freeToolCalls": 30_000, "safetyGatedCalls": 15_000, "paidComputeCalls": 150, "inferenceUsd": 0.0}, + }, + "taker": { + "description": "Signal-driven taker using strategy runs plus bursty execution and post-trade checks.", + "usage": {"seats": 2, "freeToolCalls": 4_000, "safetyGatedCalls": 1_000, "paidComputeCalls": 500, "inferenceUsd": 15.0}, + }, +} TOOL_CLASSIFICATION = [ { @@ -297,6 +328,115 @@ def runtime_c_seat(runtime_monthly_usd: float | None) -> dict[str, Any]: } +def railway_metrics_probe(args: argparse.Namespace) -> dict[str, Any]: + """Collect Railway resource metrics when the CLI is available. + + Railway metrics corroborate runtime load, but they are not invoices. C_seat + still requires an explicit monthly dollar input. + """ + railway = shutil.which("railway") + if not railway: + return {"ok": False, "blocker": "Railway CLI is not installed."} + command = [railway, "metrics", "--json", "--since", args.railway_metrics_since] + if args.railway_service: + command.extend(["--service", args.railway_service]) + if args.railway_environment: + command.extend(["--environment", args.railway_environment]) + if args.railway_project: + command.extend(["--project", args.railway_project]) + try: + result = subprocess.run(command, capture_output=True, text=True, timeout=30, check=False) + except Exception as exc: # pragma: no cover - CLI/environment dependent + return {"ok": False, "command": command[1:], "blocker": str(exc)} + if result.returncode != 0: + return { + "ok": False, + "command": command[1:], + "blocker": (result.stderr or result.stdout or "railway metrics failed")[:500], + } + try: + payload: Any = json.loads(result.stdout) + except json.JSONDecodeError: + payload = {"raw": result.stdout[:2000]} + return { + "ok": True, + "command": command[1:], + "source": "railway metrics --json", + "note": "Resource metrics are not billing dollars; provide --runtime-monthly-usd or RAILWAY_SHARED_RUNTIME_MONTHLY_USD for C_seat.", + "metrics": payload, + } + + +def _fits_plan(usage: dict[str, float], limits: dict[str, Any]) -> bool: + if usage["inferenceUsd"] > 0 and limits["inferenceUsd"] <= 0: + return False + return ( + usage["seats"] <= limits["seats"] + and usage["creditsUsed"] <= limits["credits"] + and usage["mcpCalls"] <= limits["mcpCalls"] + and usage["paidComputeCalls"] <= limits["paidComputeCalls"] + and usage["inferenceUsd"] <= limits["inferenceUsd"] + ) + + +def credits_used(usage: dict[str, float]) -> float: + credits = ( + usage["mcpCalls"] * CREDIT_RATES["mcpCallCredits"] + + usage["paidComputeCalls"] * CREDIT_RATES["paidComputeCallCredits"] + + usage["inferenceUsd"] * CREDIT_RATES["inferenceUsdCredits"] + ) + return round(credits, 2) + + +def job_profile_simulation(profile_name: str) -> dict[str, Any]: + profile = JOB_PROFILES[profile_name] + raw_usage = profile["usage"] + usage = { + "seats": raw_usage["seats"], + "freeToolCalls": raw_usage["freeToolCalls"], + "safetyGatedCalls": raw_usage["safetyGatedCalls"], + "paidComputeCalls": raw_usage["paidComputeCalls"], + "inferenceUsd": raw_usage["inferenceUsd"], + } + usage["mcpCalls"] = usage["freeToolCalls"] + usage["safetyGatedCalls"] + usage["paidComputeCalls"] + usage["creditsUsed"] = credits_used(usage) + product_prefix = "hosted-mcp-inference" if usage["inferenceUsd"] > 0 else "hosted-mcp-tools" + candidate_plans = [ + (plan_id, limits) + for plan_id, limits in PLAN_LIMITS.items() + if plan_id.startswith(product_prefix) + ] + best_plan = next((plan_id for plan_id, limits in candidate_plans if _fits_plan(usage, limits)), None) + if best_plan is None: + best_plan = candidate_plans[-1][0] + limits = PLAN_LIMITS[best_plan] + overage = { + "seats": max(0, usage["seats"] - limits["seats"]), + "credits": max(0, round((usage["creditsUsed"] - limits["credits"]) * 100) / 100), + "mcpCalls": max(0, usage["mcpCalls"] - limits["mcpCalls"]), + "paidComputeCalls": max(0, usage["paidComputeCalls"] - limits["paidComputeCalls"]), + "inferenceUsd": max(0.0, usage["inferenceUsd"] - limits["inferenceUsd"]), + } + return { + "profile": profile_name, + "description": profile["description"], + "monthlyUsage": usage, + "recommendedPlanId": best_plan, + "planLimits": limits, + "creditRates": CREDIT_RATES, + "overageAgainstRecommendedPlan": overage, + "notes": [ + "Safety-gated execution currently increments total mcpCalls.", + "Maker profiles are usually constrained by mcpCalls before seats.", + ], + } + + +def job_profile_simulations(selected: list[str]) -> dict[str, Any]: + names = selected or list(JOB_PROFILES.keys()) + return {name: job_profile_simulation(name) for name in names} + + def measure_subprocess(name: str, command: list[str], timeout: float = 30) -> Measurement: start = time.perf_counter() try: @@ -393,7 +533,7 @@ def openrouter_probe(model: str) -> dict[str, Any]: payload = json.dumps({ "model": model, "messages": [{"role": "user", "content": "Reply with exactly: ok"}], - "max_tokens": 8, + "max_tokens": OPENROUTER_PROBE_MAX_TOKENS, }).encode() req = urllib.request.Request( "https://openrouter.ai/api/v1/chat/completions", @@ -421,7 +561,8 @@ def openrouter_probe(model: str) -> dict[str, Any]: } except urllib.error.HTTPError as exc: elapsed_ms = (time.perf_counter() - start) * 1000 - return {"ok": False, "elapsedMs": elapsed_ms, "status": exc.code, "blocker": exc.reason} + detail = exc.read().decode("utf-8", errors="replace")[:1000] + return {"ok": False, "elapsedMs": elapsed_ms, "status": exc.code, "blocker": exc.reason, "detail": detail} except Exception as exc: # pragma: no cover - network dependent elapsed_ms = (time.perf_counter() - start) * 1000 return {"ok": False, "elapsedMs": elapsed_ms, "blocker": str(exc)} @@ -481,6 +622,10 @@ def build_report(args: argparse.Namespace) -> dict[str, Any]: }, "measurements": [m.__dict__ for m in measurements], "mode1": runtime_c_seat(runtime_monthly), + "railwayMetricsProbe": railway_metrics_probe(args) if args.railway_metrics else { + "ok": False, + "skipped": "Run with --railway-metrics to collect Railway resource metrics.", + }, "mode2": { "inferenceBudgetsUsd": HOSTED_INFERENCE_BUDGETS, "anchorEstimates": inference_anchor_budget_capacity(HOSTED_INFERENCE_BUDGETS), @@ -492,6 +637,7 @@ def build_report(args: argparse.Namespace) -> dict[str, Any]: "builderRevenuePer1mNotionalUsd": builder_revenue_usd(1_000_000, fee_tenths_bps), "note": "Builder revenue is formulaic until funded-wallet fills are measured.", }, + "jobProfileSimulations": job_profile_simulations(args.job_profile), "blockers": blockers, } @@ -501,6 +647,12 @@ def main() -> None: parser.add_argument("--runtime-monthly-usd", type=float, default=None) parser.add_argument("--openrouter-live", action="store_true") parser.add_argument("--openrouter-model", default=os.environ.get("NUNCHI_PRICING_OPENROUTER_MODEL", "openai/gpt-4.1-mini")) + parser.add_argument("--job-profile", action="append", choices=sorted(JOB_PROFILES.keys()), default=[]) + parser.add_argument("--railway-metrics", action="store_true") + parser.add_argument("--railway-project", default=os.environ.get("RAILWAY_PROJECT_ID", "")) + parser.add_argument("--railway-service", default=os.environ.get("RAILWAY_SERVICE_NAME", "")) + parser.add_argument("--railway-environment", default=os.environ.get("RAILWAY_ENVIRONMENT", "production")) + parser.add_argument("--railway-metrics-since", default=os.environ.get("RAILWAY_METRICS_SINCE", "1h")) parser.add_argument("--output", type=Path, default=None) args = parser.parse_args() report = build_report(args) diff --git a/tests/test_mcp_billing_command.py b/tests/test_mcp_billing_command.py new file mode 100644 index 0000000..fef5045 --- /dev/null +++ b/tests/test_mcp_billing_command.py @@ -0,0 +1,21 @@ +from typer.testing import CliRunner + +from cli.main import app + + +runner = CliRunner() + + +def test_mcp_buy_more_prints_web_auth_topup_url(monkeypatch): + monkeypatch.setenv("VITE_PAIR_API_URL", "https://web-auth.example") + monkeypatch.setenv("NUNCHI_ACCOUNT_ID", "acct_123") + + result = runner.invoke(app, ["mcp", "buy-more", "--credits", "200", "--no-open"]) + + assert result.exit_code == 0 + assert "Buy more MCP credits in web-auth:" in result.output + assert "https://web-auth.example/" in result.output + assert "view=agent-subscriptions" in result.output + assert "topUp=credits" in result.output + assert "credits=200" in result.output + assert "accountId=acct_123" in result.output diff --git a/tests/test_pricing_measure.py b/tests/test_pricing_measure.py index 98cdddd..77819b5 100644 --- a/tests/test_pricing_measure.py +++ b/tests/test_pricing_measure.py @@ -1,6 +1,20 @@ from scripts import pricing_measure as pricing +class _FakeResponse: + def __init__(self, payload): + self.payload = payload + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self): + return self.payload + + def test_builder_revenue_uses_tenths_bps(): assert pricing.builder_revenue_usd(100_000, 100) == 100.0 assert pricing.builder_revenue_usd(1_000_000, 25) == 250.0 @@ -34,6 +48,67 @@ def test_inference_anchor_budget_capacity_uses_prompt_anchors(): assert capacity["fusion"]["providedRatioVsMini"] == 146 +def test_openrouter_probe_uses_supported_token_minimum(monkeypatch): + captured = {} + + def fake_urlopen(request, timeout): + captured["timeout"] = timeout + captured["payload"] = request.data + return _FakeResponse(b'{"id":"chatcmpl_test","usage":{"cost":0.000001},"provider":"OpenAI"}') + + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-test") + monkeypatch.setattr(pricing.urllib.request, "urlopen", fake_urlopen) + + result = pricing.openrouter_probe("openai/gpt-4.1-mini") + + assert result["ok"] is True + assert pricing.json.loads(captured["payload"])["max_tokens"] >= 16 + + +def test_job_profile_simulation_recommends_tier_and_overage(): + maker = pricing.job_profile_simulation("maker") + + assert maker["recommendedPlanId"] == "hosted-mcp-tools-team" + assert maker["monthlyUsage"]["mcpCalls"] == 45_150 + assert maker["monthlyUsage"]["creditsUsed"] == 459.0 + assert maker["planLimits"]["credits"] == 500 + assert maker["creditRates"]["mcpCallCredits"] == 0.01 + assert maker["overageAgainstRecommendedPlan"]["mcpCalls"] == 0 + + +def test_report_defaults_to_all_job_profiles(): + class Args: + runtime_monthly_usd = None + openrouter_live = False + openrouter_model = "openai/gpt-4.1-mini" + job_profile = [] + railway_metrics = False + railway_project = "" + railway_service = "" + railway_environment = "production" + railway_metrics_since = "1h" + + report = pricing.build_report(Args()) + + assert set(report["jobProfileSimulations"]) == {"hedge", "maker", "taker"} + assert report["railwayMetricsProbe"]["skipped"] == "Run with --railway-metrics to collect Railway resource metrics." + + +def test_railway_metrics_probe_reports_missing_cli(monkeypatch): + class Args: + railway_metrics_since = "1h" + railway_service = "" + railway_environment = "production" + railway_project = "" + + monkeypatch.setattr(pricing.shutil, "which", lambda _name: None) + + probe = pricing.railway_metrics_probe(Args()) + + assert probe["ok"] is False + assert "Railway CLI" in probe["blocker"] + + def test_entrypoint_refusal_measurement_is_safe(): result = pricing.measure_entrypoint_tool( "funding_hedge_execute",