diff --git a/Dockerfile b/Dockerfile index 689eb70..12a9b20 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,12 @@ FROM python:3.12-slim RUN apt-get update \ - && apt-get install -y --no-install-recommends gcc g++ git \ + && apt-get install -y --no-install-recommends bash ca-certificates curl gcc g++ git \ && rm -rf /var/lib/apt/lists/* +RUN curl -fsSL https://railway.com/install.sh | bash +ENV PATH="/root/.railway/bin:${PATH}" + WORKDIR /app COPY . . RUN pip install --no-cache-dir -e ".[mcp]" diff --git a/docs/MCP_PRICING_MEASUREMENTS.md b/docs/MCP_PRICING_MEASUREMENTS.md index f56e539..d9fa01a 100644 --- a/docs/MCP_PRICING_MEASUREMENTS.md +++ b/docs/MCP_PRICING_MEASUREMENTS.md @@ -73,16 +73,36 @@ fill path is still gated by wallet credentials and testnet funding. Mode 1, hosted MCP tools: -- `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. +- `C_seat` can now be computed from live Railway resource metrics plus a + configured rate card. `--railway-metrics` captures CPU/memory/network/disk + summaries, and `pricing_measure.py` converts them into + `estimatedRailwayCostUsd`, `monthlyRunRateUsd`, and per-profile margin + outputs when these env vars are set: + - `RAILWAY_VCPU_HOUR_USD` + - `RAILWAY_MEMORY_GB_HOUR_USD` + - `RAILWAY_NETWORK_GB_USD` + - `RAILWAY_DISK_GB_HOUR_USD` + - optional `RAILWAY_BASELINE_MONTHLY_USD` +- Invoice data is now a calibration input, not a runtime dependency. Use + `RAILWAY_SHARED_RUNTIME_MONTHLY_USD` or `--runtime-monthly-usd` only when + overriding the live estimate with a reconciled invoice number. +- Production metering: run `python3 -m ops.railway_cost_meter` as a service. + It samples `railway metrics --json` continuously, applies the rate card, and + uploads global `metric_type=runtime_cost` rows to + `/api/internal/costing/railway-runtime` using + `INTERNAL_COSTING_DASHBOARD_TOKEN`/`COSTING_DASHBOARD_TOKEN`. Web-auth + persists those rows in Postgres JSONB when `SUBSCRIPTION_STORE=postgres`, + exposes rolling 1h/24h/month-to-date cost and stale-sampler state, and + allocates the shared Railway pool across active MCP subscriptions for the + internal costing dashboard. Mode 2, hosted MCP tools plus Nunchi/OpenRouter inference: -- Live OpenRouter spend can be measured with `--openrouter-live`. The probe uses - `max_tokens=16` because current OpenRouter providers reject smaller completion - caps. +- Production OpenRouter spend is metered by MCP gateway, not by the pricing + probe. The gateway-owned `openrouter_chat` MCP tool calls OpenRouter with the + server-side key and immediately uploads `metric_type=cost` rows to web-auth + with provider, model, token counts, and `usage.cost`. Use `--openrouter-live` + only as an explicit measurement probe. - 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 @@ -104,7 +124,8 @@ Mode 3, clone/local plus builder economics: ## Blockers -- Missing Railway monthly billing cost input for Mode 1 `C_seat`. +- Missing Railway rate-card env vars for live estimated Mode 1 `C_seat` if + `--railway-cost-strict` is enabled. - 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/ops/__init__.py b/ops/__init__.py new file mode 100644 index 0000000..0d3b083 --- /dev/null +++ b/ops/__init__.py @@ -0,0 +1 @@ +"""Operational metering daemons for hosted MCP subscriptions.""" diff --git a/ops/railway_cost_meter.py b/ops/railway_cost_meter.py new file mode 100644 index 0000000..40d0577 --- /dev/null +++ b/ops/railway_cost_meter.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Continuously meter shared Railway runtime cost into web-auth. + +This is the production sampler for hosted MCP Railway COGS. It is intentionally +separate from scripts/pricing_measure.py so deployments can run it as a daemon. +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + +REPO_ROOT = Path(__file__).resolve().parent.parent +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from scripts import pricing_measure as pricing # noqa: E402 + + +def internal_runtime_url(args: argparse.Namespace) -> str: + explicit = getattr(args, "internal_runtime_url", "") or os.environ.get("NUNCHI_INTERNAL_RAILWAY_RUNTIME_URL", "") + if explicit: + return explicit + base = (getattr(args, "web_auth_api_url", "") or os.environ.get("WEB_AUTH_API_URL", "") or os.environ.get("NUNCHI_WEB_AUTH_API_URL", "")).rstrip("/") + return f"{base}/api/internal/costing/railway-runtime" if base else "" + + +def internal_costing_token(args: argparse.Namespace) -> str: + return ( + getattr(args, "internal_costing_token", "") + or os.environ.get("INTERNAL_COSTING_DASHBOARD_TOKEN", "") + or os.environ.get("COSTING_DASHBOARD_TOKEN", "") + ) + + +def upload_internal_runtime_rows(url: str, token: str, rows: list[dict[str, Any]]) -> dict[str, Any]: + if not url or not token: + return {"ok": False, "blocker": "Set NUNCHI_INTERNAL_RAILWAY_RUNTIME_URL or WEB_AUTH_API_URL plus INTERNAL_COSTING_DASHBOARD_TOKEN."} + payload = json.dumps({"rows": rows}).encode() + request = urllib.request.Request( + url, + data=payload, + headers={ + "authorization": f"Bearer {token}", + "content-type": "application/json", + }, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + body = json.loads(response.read().decode()) + return {"ok": True, "url": url, "accepted": body.get("accepted"), "body": body} + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace")[:1000] + return {"ok": False, "status": exc.code, "blocker": exc.reason, "detail": detail} + except Exception as exc: # pragma: no cover - network dependent + return {"ok": False, "blocker": str(exc)} + + +def sample_once(args: argparse.Namespace) -> dict[str, Any]: + rate_card = pricing.railway_rate_card_from_env() + metrics_probe = pricing.railway_metrics_probe(args) + cost_estimate = ( + pricing.estimate_railway_cost( + metrics_probe.get("metrics", {}), + rate_card, + hours=pricing._hours_from_since(args.railway_metrics_since), + ) + if metrics_probe.get("ok") + else {"ok": False, "blocker": metrics_probe.get("blocker") or "Railway metrics unavailable."} + ) + if not cost_estimate.get("ok"): + return { + "ok": False, + "generatedAtMs": int(time.time() * 1000), + "railwayMetricsProbe": metrics_probe, + "railwayRateCard": rate_card, + "railwayCostEstimate": cost_estimate, + } + row = pricing.railway_runtime_cost_row( + cost_estimate, + service=args.railway_service, + source="railway_cost_meter", + ) + upload = upload_internal_runtime_rows( + internal_runtime_url(args), + internal_costing_token(args), + [row], + ) + return { + "ok": bool(upload.get("ok")), + "generatedAtMs": int(time.time() * 1000), + "railwayMetricsProbe": metrics_probe, + "railwayRateCard": rate_card, + "railwayCostEstimate": cost_estimate, + "upload": upload, + "rowId": row["row_id"], + } + + +def run_loop(args: argparse.Namespace) -> int: + while True: + result = sample_once(args) + print(json.dumps(result, sort_keys=True), flush=True) + if args.once: + return 0 if result.get("ok") or not args.strict else 1 + if args.strict and not result.get("ok"): + return 1 + time.sleep(max(args.interval_seconds, 5)) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Continuously upload Railway runtime COGS samples to web-auth.") + parser.add_argument("--once", action="store_true", help="Sample and upload once, then exit.") + parser.add_argument("--strict", action="store_true", help="Exit non-zero when a sample/upload fails.") + parser.add_argument("--interval-seconds", type=int, default=int(os.environ.get("RAILWAY_COST_METER_INTERVAL_SECONDS", "60"))) + parser.add_argument("--internal-runtime-url", default=os.environ.get("NUNCHI_INTERNAL_RAILWAY_RUNTIME_URL", "")) + parser.add_argument("--web-auth-api-url", default=os.environ.get("WEB_AUTH_API_URL", os.environ.get("NUNCHI_WEB_AUTH_API_URL", ""))) + parser.add_argument("--internal-costing-token", default=os.environ.get("INTERNAL_COSTING_DASHBOARD_TOKEN", os.environ.get("COSTING_DASHBOARD_TOKEN", ""))) + parser.add_argument("--railway-project", default=os.environ.get("RAILWAY_TARGET_PROJECT_ID", os.environ.get("RAILWAY_PROJECT_ID", ""))) + parser.add_argument("--railway-service", default=os.environ.get("RAILWAY_TARGET_SERVICE_NAME", 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", "1m")) + args = parser.parse_args() + raise SystemExit(run_loop(args)) + + +if __name__ == "__main__": + main() diff --git a/scripts/entrypoint.py b/scripts/entrypoint.py index 733309a..1512c4c 100644 --- a/scripts/entrypoint.py +++ b/scripts/entrypoint.py @@ -352,15 +352,18 @@ def build_command() -> list[str]: elif mode == "mcp": return py + ["mcp", "serve", "--transport", "sse"] + elif mode in ("railway_cost_meter", "cost_meter"): + return [sys.executable, "-m", "ops.railway_cost_meter"] + else: - log.error("Unknown RUN_MODE: %s. Use apex, wolf, strategy, or mcp.", mode) + log.error("Unknown RUN_MODE: %s. Use apex, wolf, strategy, mcp, or railway_cost_meter.", mode) sys.exit(1) def runner_alive() -> bool: if CHILD_PROC is not None: return CHILD_PROC.poll() is None - return os.environ.get("RUN_MODE", "mcp").lower() == "mcp" + return os.environ.get("RUN_MODE", "mcp").lower() in {"mcp", "railway_cost_meter", "cost_meter"} def handle_mcp_json_rpc(raw_body: bytes, headers: Any) -> tuple[int, dict[str, Any]]: diff --git a/scripts/pricing_measure.py b/scripts/pricing_measure.py index 700e1ad..33b718d 100644 --- a/scripts/pricing_measure.py +++ b/scripts/pricing_measure.py @@ -45,6 +45,26 @@ "inferenceUsdCredits": 1.0, } +SECONDS_PER_HOUR = 3_600 +MONTHLY_HOURS = 730 + +PLAN_PRICES_USD = { + "hosted-mcp-tools-starter": 49.0, + "hosted-mcp-tools-growth": 99.0, + "hosted-mcp-tools-team": 299.0, + "hosted-mcp-inference-starter": 99.0, + "hosted-mcp-inference-growth": 199.0, + "hosted-mcp-inference-team": 499.0, +} + +RAILWAY_RATE_ENV = { + "vcpuHourUsd": "RAILWAY_VCPU_HOUR_USD", + "memoryGbHourUsd": "RAILWAY_MEMORY_GB_HOUR_USD", + "networkGbUsd": "RAILWAY_NETWORK_GB_USD", + "diskGbHourUsd": "RAILWAY_DISK_GB_HOUR_USD", + "baselineMonthlyUsd": "RAILWAY_BASELINE_MONTHLY_USD", +} + 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}, @@ -328,6 +348,95 @@ def runtime_c_seat(runtime_monthly_usd: float | None) -> dict[str, Any]: } +def _env_float(name: str, default: float | None = None) -> float | None: + raw = os.environ.get(name) + if raw in (None, ""): + return default + return float(raw) + + +def railway_rate_card_from_env() -> dict[str, Any]: + rates = { + key: _env_float(env_name, 0.0 if key == "baselineMonthlyUsd" else None) + for key, env_name in RAILWAY_RATE_ENV.items() + } + missing = [ + env_name + for key, env_name in RAILWAY_RATE_ENV.items() + if key != "baselineMonthlyUsd" and rates[key] is None + ] + return { + "ok": len(missing) == 0, + "rates": rates, + "missing": missing, + "env": RAILWAY_RATE_ENV, + } + + +def _metric_number(metrics: dict[str, Any], path: list[str], default: float = 0.0) -> float: + cur: Any = metrics + for key in path: + if not isinstance(cur, dict): + return default + cur = cur.get(key) + try: + value = float(cur) + except (TypeError, ValueError): + return default + return value if value == value else default + + +def _hours_from_since(value: str) -> float: + text = str(value or "1h").strip().lower() + if text.endswith("m"): + return max(float(text[:-1]) / 60, 0.0) + if text.endswith("h"): + return max(float(text[:-1]), 0.0) + if text.endswith("d"): + return max(float(text[:-1]) * 24, 0.0) + try: + return max(float(text), 0.0) + except ValueError: + return 1.0 + + +def estimate_railway_cost(metrics: dict[str, Any], rate_card: dict[str, Any], *, hours: float) -> dict[str, Any]: + rates = rate_card.get("rates", rate_card) + if not rate_card.get("ok", True): + return { + "ok": False, + "blocker": "Missing Railway rate-card env vars.", + "missing": rate_card.get("missing", []), + } + cpu_vcpu = _metric_number(metrics, ["cpu", "average"]) + memory_gb = _metric_number(metrics, ["memory", "average_gb"], _metric_number(metrics, ["memory", "average_mb"]) / 1024) + network_gb = _metric_number(metrics, ["network", "egress_gb"], _metric_number(metrics, ["network", "egress_mb"]) / 1024) + disk_gb = _metric_number(metrics, ["disk", "average_gb"], _metric_number(metrics, ["disk", "average_mb"]) / 1024) + baseline_monthly = float(rates.get("baselineMonthlyUsd") or 0.0) + components = { + "cpuUsd": cpu_vcpu * hours * float(rates["vcpuHourUsd"]), + "memoryUsd": memory_gb * hours * float(rates["memoryGbHourUsd"]), + "networkUsd": network_gb * float(rates["networkGbUsd"]), + "diskUsd": disk_gb * hours * float(rates["diskGbHourUsd"]), + "baselineUsd": baseline_monthly * (hours / MONTHLY_HOURS), + } + total = sum(components.values()) + return { + "ok": True, + "estimatedRailwayCostUsd": round(total, 6), + "windowHours": hours, + "monthlyRunRateUsd": round(total * MONTHLY_HOURS / hours, 2) if hours > 0 else 0.0, + "inputs": { + "averageVcpu": cpu_vcpu, + "averageMemoryGb": memory_gb, + "networkEgressGb": network_gb, + "averageDiskGb": disk_gb, + }, + "components": {key: round(value, 6) for key, value in components.items()}, + "rateCard": rates, + } + + def railway_metrics_probe(args: argparse.Namespace) -> dict[str, Any]: """Collect Railway resource metrics when the CLI is available. @@ -362,7 +471,7 @@ def railway_metrics_probe(args: argparse.Namespace) -> dict[str, Any]: "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.", + "note": "Resource metrics are converted to estimatedRailwayCostUsd when a Railway unit-rate card is configured.", "metrics": payload, } @@ -437,6 +546,111 @@ def job_profile_simulations(selected: list[str]) -> dict[str, Any]: return {name: job_profile_simulation(name) for name in names} +def job_profile_margins(job_profiles: dict[str, Any], railway_cost_estimate: dict[str, Any]) -> dict[str, Any]: + if not railway_cost_estimate.get("ok"): + return { + name: { + "computed": False, + "blocker": railway_cost_estimate.get("blocker", "Railway cost estimate unavailable."), + } + for name in job_profiles + } + runtime_monthly = float(railway_cost_estimate.get("monthlyRunRateUsd") or 0.0) + margins: dict[str, Any] = {} + for name, profile in job_profiles.items(): + plan_id = profile["recommendedPlanId"] + limits = profile["planLimits"] + usage = profile["monthlyUsage"] + revenue = PLAN_PRICES_USD[plan_id] + included_seats = max(float(limits["seats"]), 1.0) + railway_cogs = runtime_monthly * (float(usage["seats"]) / included_seats) + inference_cogs = float(usage["inferenceUsd"]) + total_cogs = railway_cogs + inference_cogs + gross_margin = revenue - total_cogs + margins[name] = { + "computed": True, + "planId": plan_id, + "revenueUsd": revenue, + "estimatedRailwayCogsUsd": round(railway_cogs, 2), + "inferenceCogsUsd": round(inference_cogs, 2), + "totalEstimatedCogsUsd": round(total_cogs, 2), + "grossMarginUsd": round(gross_margin, 2), + "grossMarginPct": round((gross_margin / revenue) * 100, 2) if revenue else None, + "allocation": { + "method": "profile seats / plan included seats", + "profileSeats": usage["seats"], + "includedSeats": limits["seats"], + "runtimeMonthlyRunRateUsd": runtime_monthly, + }, + } + return margins + + +def railway_runtime_cost_row(cost_estimate: dict[str, Any], *, service: str = "", ts_ms: int | None = None, source: str = "pricing_measure_railway_sampler") -> dict[str, Any]: + ts = ts_ms or int(time.time() * 1000) + service_key = service or "shared" + return { + "row_id": f"railway_runtime_cost:{service_key}:{ts}", + "metric_type": "runtime_cost", + "row": { + "ts": ts, + "estimated_railway_cost_usd": cost_estimate.get("estimatedRailwayCostUsd", 0), + "window_hours": cost_estimate.get("windowHours", 0), + "monthly_run_rate_usd": cost_estimate.get("monthlyRunRateUsd", 0), + "components": cost_estimate.get("components", {}), + "service": service or None, + "source": source, + }, + } + + +def upload_runtime_cost_row(args: argparse.Namespace, cost_estimate: dict[str, Any]) -> dict[str, Any]: + if not getattr(args, "emit_railway_runtime_cost", False): + return {"ok": False, "skipped": "Run with --emit-railway-runtime-cost to upload live cost samples."} + if not cost_estimate.get("ok"): + return {"ok": False, "blocker": cost_estimate.get("blocker", "Railway cost estimate unavailable.")} + url = getattr(args, "metering_usage_url", "") or os.environ.get("NUNCHI_METERING_USAGE_URL") or os.environ.get("MCP_METERING_USAGE_URL") + token = getattr(args, "metering_token", "") or os.environ.get("NUNCHI_METERING_TOKEN") or os.environ.get("NUNCHI_WEB_AUTH_PAIR_TOKEN") + account_id = getattr(args, "account_id", "") or os.environ.get("NUNCHI_ACCOUNT_ID", "") + subscription_id = getattr(args, "subscription_id", "") or os.environ.get("NUNCHI_SUBSCRIPTION_ID", "") + plan_id = getattr(args, "plan_id", "") or os.environ.get("NUNCHI_PLAN_ID", "hosted-mcp-tools-starter") + if not url or not token or not account_id: + return { + "ok": False, + "blocker": "Set metering URL, token, and account ID before emitting Railway runtime cost.", + } + row = railway_runtime_cost_row(cost_estimate, service=args.railway_service) + payload = json.dumps({ + "accountId": account_id, + "subscriptionId": subscription_id, + "planId": plan_id, + "rows": [row], + }).encode() + request = urllib.request.Request( + url, + data=payload, + headers={ + "authorization": f"Bearer {token}", + "content-type": "application/json", + }, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + body = json.loads(response.read().decode()) + return { + "ok": True, + "url": url, + "rowId": row["row_id"], + "accepted": body.get("accepted"), + } + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace")[:1000] + return {"ok": False, "status": exc.code, "blocker": exc.reason, "detail": detail} + except Exception as exc: # pragma: no cover - network dependent + return {"ok": False, "blocker": str(exc)} + + def measure_subprocess(name: str, command: list[str], timeout: float = 30) -> Measurement: start = time.perf_counter() try: @@ -577,6 +791,7 @@ def parse_runtime_monthly(args: argparse.Namespace) -> float | None: def build_report(args: argparse.Namespace) -> dict[str, Any]: runtime_monthly = parse_runtime_monthly(args) + railway_rate_card = railway_rate_card_from_env() fee_tenths_bps = builder_fee_rate_tenths_bps() measurements = [ measure_subprocess("python.import_cli", [sys.executable, "-c", "import cli.main; print('ok')"]), @@ -591,9 +806,30 @@ def build_report(args: argparse.Namespace) -> dict[str, Any]: "blocker": "Run with --openrouter-live to spend OpenRouter credits for this probe.", "credentialPresent": env_flag("OPENROUTER_API_KEY"), } + railway_metrics = railway_metrics_probe(args) if args.railway_metrics else { + "ok": False, + "skipped": "Run with --railway-metrics to collect Railway resource metrics.", + } + railway_cost_estimate = ( + estimate_railway_cost( + railway_metrics.get("metrics", {}), + railway_rate_card, + hours=_hours_from_since(args.railway_metrics_since), + ) + if railway_metrics.get("ok") + else {"ok": False, "blocker": railway_metrics.get("skipped") or railway_metrics.get("blocker") or "Railway metrics unavailable."} + ) + runtime_monthly_for_c_seat = runtime_monthly + if runtime_monthly_for_c_seat is None and railway_cost_estimate.get("ok"): + runtime_monthly_for_c_seat = float(railway_cost_estimate["monthlyRunRateUsd"]) + job_profiles = job_profile_simulations(args.job_profile) + railway_cost_upload = upload_runtime_cost_row(args, railway_cost_estimate) + blockers: list[str] = [] - if runtime_monthly is None: - blockers.append("missing Railway runtime monthly cost input for Mode 1 C_seat") + if runtime_monthly_for_c_seat is None: + blockers.append("missing Railway runtime monthly cost estimate for Mode 1 C_seat") + if getattr(args, "railway_cost_strict", False) and not railway_cost_estimate.get("ok"): + blockers.append(railway_cost_estimate.get("blocker", "Railway cost estimate unavailable in strict mode.")) if not has_wallet_credentials(): blockers.append("missing funded-wallet/HL signing credentials for live funded-wallet measurement") if not env_flag("OPENROUTER_API_KEY"): @@ -621,11 +857,11 @@ def build_report(args: argparse.Namespace) -> dict[str, Any]: "builderFeeTenthsBps": fee_tenths_bps, }, "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.", - }, + "mode1": runtime_c_seat(runtime_monthly_for_c_seat), + "railwayRateCard": railway_rate_card, + "railwayMetricsProbe": railway_metrics, + "railwayCostEstimate": railway_cost_estimate, + "railwayRuntimeCostUpload": railway_cost_upload, "mode2": { "inferenceBudgetsUsd": HOSTED_INFERENCE_BUDGETS, "anchorEstimates": inference_anchor_budget_capacity(HOSTED_INFERENCE_BUDGETS), @@ -637,7 +873,8 @@ 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), + "jobProfileSimulations": job_profiles, + "jobProfileMargins": job_profile_margins(job_profiles, railway_cost_estimate), "blockers": blockers, } @@ -653,6 +890,13 @@ def main() -> None: 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("--railway-cost-strict", action="store_true") + parser.add_argument("--emit-railway-runtime-cost", action="store_true") + parser.add_argument("--metering-usage-url", default=os.environ.get("NUNCHI_METERING_USAGE_URL", "")) + parser.add_argument("--metering-token", default=os.environ.get("NUNCHI_METERING_TOKEN", "")) + parser.add_argument("--account-id", default=os.environ.get("NUNCHI_ACCOUNT_ID", "")) + parser.add_argument("--subscription-id", default=os.environ.get("NUNCHI_SUBSCRIPTION_ID", "")) + parser.add_argument("--plan-id", default=os.environ.get("NUNCHI_PLAN_ID", "hosted-mcp-tools-starter")) parser.add_argument("--output", type=Path, default=None) args = parser.parse_args() report = build_report(args) diff --git a/tests/test_pricing_measure.py b/tests/test_pricing_measure.py index 77819b5..8d5c1c3 100644 --- a/tests/test_pricing_measure.py +++ b/tests/test_pricing_measure.py @@ -1,4 +1,5 @@ from scripts import pricing_measure as pricing +from ops import railway_cost_meter class _FakeResponse: @@ -32,6 +33,136 @@ def test_runtime_c_seat_requires_explicit_input(): assert computed["byPlan"]["team"]["cSeatUsd"] == 5 +def test_railway_rate_card_and_cost_estimate(monkeypatch): + monkeypatch.setenv("RAILWAY_VCPU_HOUR_USD", "0.10") + monkeypatch.setenv("RAILWAY_MEMORY_GB_HOUR_USD", "0.01") + monkeypatch.setenv("RAILWAY_NETWORK_GB_USD", "0.05") + monkeypatch.setenv("RAILWAY_DISK_GB_HOUR_USD", "0.02") + monkeypatch.setenv("RAILWAY_BASELINE_MONTHLY_USD", "73") + + rate_card = pricing.railway_rate_card_from_env() + estimate = pricing.estimate_railway_cost( + { + "cpu": {"average": 2.0}, + "memory": {"average_mb": 1024}, + "network": {"egress_gb": 3.0}, + "disk": {"average_gb": 4.0}, + }, + rate_card, + hours=10, + ) + + assert rate_card["ok"] is True + assert estimate["ok"] is True + assert estimate["components"]["cpuUsd"] == 2.0 + assert estimate["components"]["memoryUsd"] == 0.1 + assert estimate["components"]["networkUsd"] == 0.15 + assert estimate["components"]["diskUsd"] == 0.8 + assert estimate["components"]["baselineUsd"] == 1.0 + assert estimate["estimatedRailwayCostUsd"] == 4.05 + + +def test_job_profile_margins_use_railway_cost_estimate(): + profiles = {"maker": pricing.job_profile_simulation("maker")} + margins = pricing.job_profile_margins( + profiles, + {"ok": True, "monthlyRunRateUsd": 250.0}, + ) + + maker = margins["maker"] + assert maker["computed"] is True + assert maker["planId"] == "hosted-mcp-tools-team" + assert maker["revenueUsd"] == 299.0 + assert maker["estimatedRailwayCogsUsd"] == 25.0 + assert maker["inferenceCogsUsd"] == 0.0 + assert maker["grossMarginUsd"] == 274.0 + + +def test_railway_runtime_cost_row_shape(): + row = pricing.railway_runtime_cost_row( + { + "estimatedRailwayCostUsd": 1.23, + "windowHours": 1, + "monthlyRunRateUsd": 897.9, + "components": {"cpuUsd": 0.5}, + }, + service="hosted-trading-mcp", + ts_ms=123, + ) + + assert row["metric_type"] == "runtime_cost" + assert row["row_id"] == "railway_runtime_cost:hosted-trading-mcp:123" + assert row["row"]["estimated_railway_cost_usd"] == 1.23 + assert row["row"]["monthly_run_rate_usd"] == 897.9 + + +def test_railway_cost_meter_uses_internal_endpoint(): + class Args: + internal_runtime_url = "" + web_auth_api_url = "https://web-auth.example" + + assert railway_cost_meter.internal_runtime_url(Args()) == "https://web-auth.example/api/internal/costing/railway-runtime" + + +def test_railway_cost_meter_prefers_target_service_env(monkeypatch): + monkeypatch.setenv("RAILWAY_TARGET_SERVICE_NAME", "hosted-trading-mcp") + monkeypatch.setenv("RAILWAY_SERVICE_NAME", "railway-cost-meter") + monkeypatch.setenv("RAILWAY_TARGET_PROJECT_ID", "project-target") + monkeypatch.setenv("RAILWAY_PROJECT_ID", "project-self") + + parser = railway_cost_meter.argparse.ArgumentParser() + parser.add_argument("--railway-project", default=railway_cost_meter.os.environ.get("RAILWAY_TARGET_PROJECT_ID", railway_cost_meter.os.environ.get("RAILWAY_PROJECT_ID", ""))) + parser.add_argument("--railway-service", default=railway_cost_meter.os.environ.get("RAILWAY_TARGET_SERVICE_NAME", railway_cost_meter.os.environ.get("RAILWAY_SERVICE_NAME", ""))) + args = parser.parse_args([]) + + assert args.railway_service == "hosted-trading-mcp" + assert args.railway_project == "project-target" + + +def test_railway_cost_meter_samples_and_uploads_global_runtime(monkeypatch): + class Args: + internal_runtime_url = "https://web-auth.example/api/internal/costing/railway-runtime" + web_auth_api_url = "" + internal_costing_token = "internal-token" + railway_metrics_since = "1m" + railway_service = "hosted-trading-mcp" + railway_environment = "production" + railway_project = "" + + captured = {} + monkeypatch.setattr(railway_cost_meter.pricing, "railway_rate_card_from_env", lambda: {"ok": True, "rates": { + "vcpuHourUsd": 0.1, + "memoryGbHourUsd": 0.01, + "networkGbUsd": 0.05, + "diskGbHourUsd": 0.02, + "baselineMonthlyUsd": 0, + }}) + monkeypatch.setattr(railway_cost_meter.pricing, "railway_metrics_probe", lambda _args: {"ok": True, "metrics": {"cpu": {"average": 1}}}) + monkeypatch.setattr(railway_cost_meter.pricing, "estimate_railway_cost", lambda *_args, **_kwargs: { + "ok": True, + "estimatedRailwayCostUsd": 0.1, + "windowHours": 1 / 60, + "monthlyRunRateUsd": 73, + "components": {"cpuUsd": 0.1}, + }) + + def fake_upload(url, token, rows): + captured["url"] = url + captured["token"] = token + captured["rows"] = rows + return {"ok": True, "accepted": 1} + + monkeypatch.setattr(railway_cost_meter, "upload_internal_runtime_rows", fake_upload) + + result = railway_cost_meter.sample_once(Args()) + + assert result["ok"] is True + assert captured["url"].endswith("/api/internal/costing/railway-runtime") + assert captured["token"] == "internal-token" + assert captured["rows"][0]["metric_type"] == "runtime_cost" + assert captured["rows"][0]["row"]["source"] == "railway_cost_meter" + + def test_tool_classification_counts_match_task7_buckets(): counts = pricing.tool_bucket_counts() assert counts["free_read"] == 15