From d1f2edf6d566d97d6bbf42242c0cc105773b358f Mon Sep 17 00:00:00 2001 From: Ram Viswanadha Date: Sun, 27 Sep 2026 12:39:41 -0700 Subject: [PATCH 1/6] docs: clarify that LineageLens is 100% local, offline, and login-optional --- README.md | 9 ++++++++- src/lineagelens/cli.py | 1 + 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1e43d42..9b83f66 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,15 @@ and the honest statement of what it could not determine. pip install lineagelens lineagelens index . lineagelens query . impact src/orders/service.py:412 + +# Or run with Docker (zero install, zero host toolchain pollution): +docker run --rm -v $(pwd):/workspace -w /workspace ghcr.io/contextserve/lineagelens:latest lineagelens index . +docker run --rm -v $(pwd):/workspace -w /workspace ghcr.io/contextserve/lineagelens:latest lineagelens query . impact src/orders/service.py:412 ``` +> **100% Local & Offline**: LineageLens requires no account, no login, and no API key. All indexing, call graph generation, and MCP queries run entirely inside your local machine in SQLite (`.lineagelens/graph.db`). Zero telemetry by default. + + ## What it answers | Question | Command | @@ -367,7 +374,7 @@ All stages have been optimized to clean their APT caches to keep the image sizes Build the index ``` -docker run --rm -v $(pwd):/workspace registry.contextserve.ai/lineagelens/cli:latest lineagelens index +docker run --rm -v $(pwd):/workspace ghcr.io/contextserve/lineagelens:latest lineagelens index ``` ## Publishing to GHCR diff --git a/src/lineagelens/cli.py b/src/lineagelens/cli.py index d32d1d1..1f618a6 100644 --- a/src/lineagelens/cli.py +++ b/src/lineagelens/cli.py @@ -1126,6 +1126,7 @@ def _run_auth(args: Any) -> int: print() print(f" Active environment: {active}") + print(" • Note: Authentication is optional. Local indexing, CLI queries, and MCP work 100% offline without an account.") print() # ── token ───────────────────────────────────────────────────────────────── From 537c26f1dc048149451c54dd64b7e7f6ec1f5b98 Mon Sep 17 00:00:00 2001 From: Ram Viswanadha Date: Sun, 27 Sep 2026 22:04:51 -0700 Subject: [PATCH 2/6] feat(mcp): add local SQLite metrics persistence and token savings reporter - Record MCP tool executions, token metrics, latency, and provenance in .lineagelens/metrics.sqlite - Deterministic ground-truth counterfactual baseline (raw_tokens) vs surgical response (optimized_tokens) - Add 'lineagelens report' CLI subcommand with --period, --by-tool, --sync, --json, --model, and --reset - Support batch sync to ContextServe.ai dashboard via 'lineagelens report --sync' - Unit and integration tests for MetricsStore, sync engine, and report CLI --- README.md | 14 ++ docs/TELEMETRY.md | 21 ++ src/lineagelens/cli.py | 49 ++++- src/lineagelens/mcp/server.py | 180 ++++++++++++++- src/lineagelens/report.py | 187 ++++++++++++++++ src/lineagelens/store/__init__.py | 4 + src/lineagelens/store/metrics_store.py | 289 +++++++++++++++++++++++++ src/lineagelens/sync.py | 141 ++++++++++++ tests/test_metrics_store.py | 189 ++++++++++++++++ tests/test_report.py | 94 ++++++++ tests/test_sync.py | 89 ++++++++ 11 files changed, 1254 insertions(+), 3 deletions(-) create mode 100644 src/lineagelens/report.py create mode 100644 src/lineagelens/store/metrics_store.py create mode 100644 src/lineagelens/sync.py create mode 100644 tests/test_metrics_store.py create mode 100644 tests/test_report.py create mode 100644 tests/test_sync.py diff --git a/README.md b/README.md index 9b83f66..4c45820 100644 --- a/README.md +++ b/README.md @@ -207,10 +207,24 @@ lineagelens verify [path] rebuild twice, confirm identical lineagelens ontology [path] measured capability for this installation lineagelens mcp [path] run the MCP server over stdio lineagelens serve [path] HTTP surface for the dashboard (loopback default) +lineagelens report [path] local SQLite token savings & MCP ROI reporter lineagelens telemetry ... enable, disable, status -- off until you enable it lineagelens auth ... login, logout, status, token, switch-env ``` +## Local Token Savings & MCP Metrics + +Every MCP tool invocation computes the ground-truth counterfactual baseline (source tokens an LLM would have read without LineageLens) versus the optimized response payload, saving the metrics locally to `.lineagelens/metrics.sqlite`: + +```bash +lineagelens report # View net tokens saved, context reduction %, and ROI +lineagelens report --by-tool # Tool-by-tool breakdown +lineagelens report --period 7d # Filter by time window (today, 7d, 30d, all) +lineagelens report --model o1 # Price against target LLMs (gpt-4o, claude-3-5-sonnet, deepseek-r1, o1, o3-mini) +lineagelens report --sync # Sync metrics to ContextServe.ai dashboard +lineagelens report --json # Machine-readable JSON output +``` + ## Telemetry Off until you turn it on. `DO_NOT_TRACK` and `CI` suppress it permanently and diff --git a/docs/TELEMETRY.md b/docs/TELEMETRY.md index 9782b50..d194cc3 100644 --- a/docs/TELEMETRY.md +++ b/docs/TELEMETRY.md @@ -153,3 +153,24 @@ Anonymous events: aggregated counts retained indefinitely, raw events 90 days. Authenticated metering follows your organisation's plan. Both are server-side concerns and live in the hosted service's own policy; this document covers what the client sends. + +## Local SQLite Metrics & Token Savings Reporter (`lineagelens report`) + +When running offline or unauthenticated, invocation metrics and token reduction telemetry are persisted locally to `.lineagelens/metrics.sqlite` using SQLite WAL mode with zero latency overhead. + +```bash +lineagelens report # Visual summary table and cost savings +lineagelens report --by-tool # Detailed per-tool breakdown +lineagelens report --period 7d # Filter by time window (today, 7d, 30d, all) +lineagelens report --model claude-3-5-sonnet # Pricing model baseline +lineagelens report --sync # Report unsynced metrics to ContextServe.ai +lineagelens report --json # Machine-readable JSON output +lineagelens report --reset -y # Reset local metrics history +``` + +### Counterfactual Baseline vs. Optimized Tokens +- **Raw Tokens (Counterfactual)**: Measures the tokens an agent would have spent loading full source files containing the touched nodes into its context window. +- **Optimized Tokens**: The surgical LineageLens JSON response size converted at 4 bytes per token. +- **Tokens Saved**: `raw_tokens - optimized_tokens`. +- **Database Safety**: Stored in `.lineagelens/metrics.sqlite`, ensuring metrics outlive code graph rebuilds (`lineagelens index . --force`). + diff --git a/src/lineagelens/cli.py b/src/lineagelens/cli.py index 1f618a6..8de0048 100644 --- a/src/lineagelens/cli.py +++ b/src/lineagelens/cli.py @@ -55,7 +55,7 @@ def main(argv: list[str] | None = None) -> int: _add_serve(commands) _add_telemetry(commands) _add_auth(commands) - + _add_report(commands) args = parser.parse_args(argv) logging.basicConfig( @@ -1157,5 +1157,52 @@ def _run_auth(args: Any) -> int: return 0 +# --------------------------------------------------------------------------- +# report +# --------------------------------------------------------------------------- + +def _add_report(commands: Any) -> None: + p = commands.add_parser( + "report", + help="Report MCP tool token savings, usage metrics, and ContextServe ROI", + ) + p.add_argument("path", nargs="?", default=".", type=Path, + help="Path to project root (default: .)") + p.add_argument("--period", choices=["today", "7d", "30d", "all"], default="all", + help="Time window filter (default: all)") + p.add_argument("--by-tool", action="store_true", + help="Display breakdown table for each individual MCP tool") + p.add_argument("--sync", action="store_true", + help="Upload unsynced local metrics to ContextServe.ai") + p.add_argument("--endpoint", default=None, + help="Override ContextServe.ai API endpoint") + p.add_argument("--json", action="store_true", dest="json_output", + help="Output machine-readable JSON") + p.add_argument("--model", default="gpt-4o", + choices=["gpt-4o", "claude-3-5-sonnet", "deepseek-r1", "o1", "o3-mini"], + help="Pricing model baseline (default: gpt-4o)") + p.add_argument("--reset", action="store_true", + help="Wipe local metrics history") + p.add_argument("-y", "--yes", action="store_true", + help="Confirm reset without prompting") + p.set_defaults(handler=_run_report) + + +def _run_report(args: Any) -> int: + from .report import run_report + + return run_report( + project_root=args.path, + period=args.period, + by_tool=args.by_tool, + sync=args.sync, + endpoint=args.endpoint, + json_output=args.json_output, + model=args.model, + reset=args.reset, + yes=args.yes, + ) + + if __name__ == "__main__": # pragma: no cover raise SystemExit(main()) diff --git a/src/lineagelens/mcp/server.py b/src/lineagelens/mcp/server.py index 79b68d5..85b4c66 100644 --- a/src/lineagelens/mcp/server.py +++ b/src/lineagelens/mcp/server.py @@ -29,10 +29,13 @@ from __future__ import annotations +import asyncio +import contextlib import functools import inspect import logging import os +import subprocess from pathlib import Path from typing import Any @@ -55,7 +58,6 @@ def _project_root() -> Path: return Path(os.environ.get(PROJECT_ENV, ".")).resolve() def _get_git_info() -> tuple[str | None, str | None]: - import subprocess branch, commit_sha = None, None try: branch = subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"], stderr=subprocess.DEVNULL, text=True).strip() @@ -65,6 +67,133 @@ def _get_git_info() -> tuple[str | None, str | None]: return branch, commit_sha +def _extract_nodes_and_files(data: Any, max_depth: int = 5) -> tuple[set[str], set[str]]: + node_ids: set[str] = set() + file_paths: set[str] = set() + + def _walk(obj: Any, current_depth: int) -> None: + if current_depth > max_depth: + return + if isinstance(obj, dict): + for k, v in obj.items(): + if k in ("node", "node_id", "symbol", "target", "from_symbol", "to_symbol") and isinstance(v, str): + node_ids.add(v) + elif k in ("file", "file_path", "path") and isinstance(v, str): + file_paths.add(v) + elif k in ("at", "written_at") and isinstance(v, str): + path = v.split(":")[0] + if path: + file_paths.add(path) + elif isinstance(v, (dict, list)): + _walk(v, current_depth + 1) + elif isinstance(obj, list): + for item in obj: + if isinstance(item, (dict, list)): + _walk(item, current_depth + 1) + + _walk(data, 0) + return node_ids, file_paths + + +def compute_token_impact( + engine: QueryEngine | None, + tool_name: str, + result: Any, + response_bytes: int, +) -> tuple[int, int]: + """Calculate ground-truth counterfactual tokens and optimized response tokens. + + Counterfactual tokens are based on distinct files containing nodes touched by + the query engine. Fallback floor is max(optimized_tokens * 8, 1200). + """ + from ..telemetry import BYTES_PER_TOKEN + + optimized_tokens = max(1, response_bytes // BYTES_PER_TOKEN) + raw_tokens = 0 + + if engine is not None and isinstance(result, dict) and "error" not in result: + try: + node_ids, file_paths = _extract_nodes_and_files(result) + store = getattr(engine, "store", None) + if store and hasattr(store, "conn"): + sizes: set[int] = set() + if node_ids: + placeholders = ",".join("?" for _ in node_ids) + query = f""" + SELECT DISTINCT f.id, f.size_bytes + FROM files f + JOIN nodes n ON n.file_id = f.id + WHERE n.id IN ({placeholders}) OR n.qualified_name IN ({placeholders}) + """ # noqa: S608 + params = list(node_ids) + list(node_ids) + rows = store.conn.execute(query, params).fetchall() + for r in rows: + sizes.add(r["size_bytes"]) + + if file_paths: + placeholders = ",".join("?" for _ in file_paths) + query = f"SELECT DISTINCT id, size_bytes FROM files WHERE path IN ({placeholders})" # noqa: S608 + rows = store.conn.execute(query, list(file_paths)).fetchall() + for r in rows: + sizes.add(r["size_bytes"]) + + if sizes: + raw_tokens = sum(s // BYTES_PER_TOKEN for s in sizes) + except Exception: + logger.debug("Error computing graph-attributed token impact", exc_info=True) + + # Conservative fallback floor (Section 4.1) + raw_tokens = max(raw_tokens, optimized_tokens * 8, 1200) + return raw_tokens, optimized_tokens + + +def record_local_metric( + db_path: Path, + tool_name: str, + raw_tokens: int, + optimized_tokens: int, + duration_ms: int, + response_bytes: int, + repo_name: str, + branch: str | None = None, + commit_sha: str | None = None, + status: str = "ok", + error_message: str | None = None, + intent: str | None = None, + result_count: int = 0, + truncated: bool = False, + model_name: str = "gpt-4o", +) -> None: + """Record an invocation metric into local SQLite.""" + try: + from ..store.metrics_store import MetricRecord, MetricsStore + + tokens_saved = max(0, raw_tokens - optimized_tokens) + store = MetricsStore(db_path) + record = MetricRecord( + tool_name=tool_name, + query_type=f"mcp_{tool_name}", + raw_tokens=raw_tokens, + optimized_tokens=optimized_tokens, + tokens_saved=tokens_saved, + duration_ms=duration_ms, + response_bytes=response_bytes, + status=status, + error_message=error_message, + intent=intent, + result_count=result_count, + truncated=1 if truncated else 0, + repo_name=repo_name, + branch=branch, + commit_sha=commit_sha, + model_name=model_name, + ) + store.record(record) + except Exception: + logger.debug("Failed to record local metric for %s", tool_name, exc_info=True) + + + async def _send_telemetry(tool_name: str, raw_tokens: int, optimized_tokens: int) -> None: """Emit telemetry payload in the background.""" import os @@ -94,6 +223,7 @@ async def _send_telemetry(tool_name: str, raw_tokens: int, optimized_tokens: int "query_type": f"mcp_{tool_name}", "raw_tokens": raw_tokens, "optimized_tokens": optimized_tokens, + "tokens_saved": max(0, raw_tokens - optimized_tokens), "repo_name": repo_name, "branch": branch, "commit_sha": commit_sha, @@ -213,6 +343,8 @@ async def wrapped(*args: Any, **kwargs: Any) -> dict[str, Any]: # rather than by remembering (#59). from .. import telemetry + status = "ok" + error_msg = None with telemetry.timed() as clock: try: result = await fn(*args, **kwargs) @@ -221,11 +353,48 @@ async def wrapped(*args: Any, **kwargs: Any) -> dict[str, Any]: "error": "no index", "remedy": f"run: lineagelens index {project}", } + status = "error" + error_msg = "no index" except SchemaMismatch as exc: result = { "error": str(exc), "remedy": f"run: lineagelens index {project} --force", } + status = "error" + error_msg = str(exc) + except Exception as exc: + status = "error" + error_msg = str(exc) + raise + + response_bytes = _response_bytes(result) + current_engine = None + with contextlib.suppress(Exception): + current_engine = engine() + + raw_tokens, optimized_tokens = compute_token_impact( + current_engine, fn.__name__, result, response_bytes + ) + + branch, commit_sha = _get_git_info() + record_local_metric( + db_path=project / ".lineagelens" / "metrics.sqlite", + tool_name=fn.__name__, + raw_tokens=raw_tokens, + optimized_tokens=optimized_tokens, + duration_ms=clock.ms, + response_bytes=response_bytes, + repo_name=project.name, + branch=branch, + commit_sha=commit_sha, + status=status, + error_message=error_msg, + intent=kwargs.get("intent"), + result_count=len(result.get("results", ())) + if isinstance(result, dict) else 0, + truncated=bool(result.get("truncated")) + if isinstance(result, dict) else False, + ) # A counter, not I/O. Metering a call must not become the cost it # is measuring, so nothing is sent until the process exits. @@ -233,7 +402,7 @@ async def wrapped(*args: Any, **kwargs: Any) -> dict[str, Any]: telemetry.meter().record_tool_call(telemetry.ToolCall( tool=fn.__name__, duration_ms=clock.ms, - response_bytes=_response_bytes(result), + response_bytes=response_bytes, # `precise` adds verbatim source and data flow, so it is # the single biggest cost lever an agent controls. intent=kwargs.get("intent"), @@ -245,6 +414,13 @@ async def wrapped(*args: Any, **kwargs: Any) -> dict[str, Any]: except Exception: # pragma: no cover - metering never breaks a tool logger.debug("metering failed for %s", fn.__name__, exc_info=True) + try: + task = asyncio.create_task(_send_telemetry(fn.__name__, raw_tokens, optimized_tokens)) + _background_tasks.add(task) + task.add_done_callback(_background_tasks.discard) + except Exception: + pass + return result # Advertise the tool's real parameters minus each signature's ``**_`` diff --git a/src/lineagelens/report.py b/src/lineagelens/report.py new file mode 100644 index 0000000..ed0319a --- /dev/null +++ b/src/lineagelens/report.py @@ -0,0 +1,187 @@ +"""MCP Token Savings and ROI Reporter. + +Generates visual terminal tables or machine-readable JSON summarizing local +MCP tool execution metrics, counterfactual token savings, and cloud synchronization. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path +from typing import Any + +from .store.metrics_store import PRICING_MODELS, MetricsStore +from .sync import get_auth_token_and_base_url, sync_metrics + + +def _get_git_info(cwd: Path) -> tuple[str | None, str | None]: + branch, commit_sha = None, None + try: + branch = subprocess.check_output( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + stderr=subprocess.DEVNULL, + cwd=str(cwd), + text=True, + ).strip() + commit_sha = subprocess.check_output( + ["git", "rev-parse", "HEAD"], + stderr=subprocess.DEVNULL, + cwd=str(cwd), + text=True, + ).strip() + except Exception: + pass + return branch, commit_sha + + +def generate_report_data( + project_root: Path, + period: str = "all", + model: str = "gpt-4o", +) -> dict[str, Any]: + """Compile structured metrics report data.""" + store = MetricsStore.for_project(project_root) + branch, _ = _get_git_info(project_root) + sync_status = store.get_cloud_sync_status() + summary = store.get_summary(period=period, model_name=model) + by_tool = store.get_by_tool(period=period) + + return { + "repo_name": project_root.resolve().name, + "branch": branch or "unknown", + "period": period, + "cloud_sync": sync_status, + "summary": summary, + "by_tool": by_tool, + } + + +def format_terminal_report( + data: dict[str, Any], + by_tool: bool = False, + endpoint: str | None = None, +) -> str: + """Format metrics report as a clean, high-impact terminal view.""" + repo = data["repo_name"] + branch = data.get("branch", "unknown") + period = data.get("period", "all") + model = data["summary"].get("model_name", "gpt-4o") + rate = PRICING_MODELS.get(model, PRICING_MODELS["gpt-4o"]) + + period_labels = { + "all": "All Time", + "today": "Today", + "7d": "Last 7 Days", + "30d": "Last 30 Days", + } + period_str = period_labels.get(period, period) + + sync_info = data["cloud_sync"] + total_recs = sync_info["total"] + synced_recs = sync_info["synced"] + pending_recs = sync_info["pending"] + + _, _, org = get_auth_token_and_base_url(endpoint) + org_label = f" (org: {org})" if org else "" + + if pending_recs == 0 and total_recs > 0: + sync_line = f"{synced_recs} / {total_recs} synced to ContextServe.ai{org_label}" + elif total_recs == 0: + sync_line = "0 / 0 synced to ContextServe.ai" + else: + sync_line = f"{synced_recs} / {total_recs} synced to ContextServe.ai ({pending_recs} pending, run with --sync){org_label}" + + s = data["summary"] + total_calls = s["total_invocations"] + raw_tokens = s["raw_tokens"] + opt_tokens = s["optimized_tokens"] + saved_tokens = s["tokens_saved"] + reduction = s["reduction_percentage"] + savings_usd = s["cost_savings_usd"] + avg_duration = s["avg_duration_ms"] + + lines = [ + "=" * 88, + " LINEAGELENS MCP TOKEN SAVINGS REPORT", + f" Repository: {repo} (branch: {branch})", + f" Period : {period_str}", + f" Target : {model} (${rate:.2f} / 1M blended tokens)", + f" Cloud Sync: {sync_line}", + "=" * 88, + "", + " TOTAL INVOCATIONS BASELINE TOKENS OPTIMIZED TOKENS NET TOKENS SAVED", + f" {total_calls:<17} {raw_tokens:>15,} {opt_tokens:>15,} {saved_tokens:>15,}", + f" (Unindexed Context) (LineageLens MCP) ({reduction:.1f}% Reduction)", + "", + f" ESTIMATED COST SAVINGS: ${savings_usd:.2f} USD", + ] + + if by_tool and data["by_tool"]: + lines.extend([ + "", + "TOOL BREAKDOWN:", + f"{'Tool':<20} {'Invocations':>11} {'Raw Tokens':>11} {'Optimized':>9} {'Tokens Saved':>12} {'Reduction':>9} {'Avg Latency':>11}", + "-" * 96, + ]) + for tool_name, t in data["by_tool"].items(): + lines.append( + f"{tool_name:<20} {t['calls']:>11} {t['raw_tokens']:>11,} {t['optimized_tokens']:>9,} " + f"{t['tokens_saved']:>12,} {t['reduction_percentage']:>8.1f}% {int(t['avg_duration_ms']):>9}ms" + ) + lines.extend([ + "-" * 96, + f"{'TOTAL':<20} {total_calls:>11} {raw_tokens:>11,} {opt_tokens:>9,} " + f"{saved_tokens:>12,} {reduction:>8.1f}% {int(avg_duration):>9}ms", + ]) + + lines.append("=" * 88) + return "\n".join(lines) + + +def run_report( + project_root: Path, + period: str = "all", + by_tool: bool = False, + sync: bool = False, + endpoint: str | None = None, + json_output: bool = False, + model: str = "gpt-4o", + reset: bool = False, + yes: bool = False, +) -> int: + """Execute report subcommand logic.""" + project_root = project_root.resolve() + store = MetricsStore.for_project(project_root) + + if reset: + if not yes: + answer = input("Are you sure you want to reset all local metrics? [y/N]: ").strip().lower() + if answer not in ("y", "yes"): + print("Reset cancelled.") + return 0 + store.reset() + print("Local metrics history reset successfully.") + return 0 + + if sync: + print("Connecting to ContextServe.ai...") + status = store.get_cloud_sync_status() + pending = status["pending"] + if pending == 0: + print("No unsynced metrics found. Everything up to date.") + else: + print(f"Found {pending} unsynced invocation metrics.") + ok, msg, _ = sync_metrics(project_root, endpoint=endpoint) + print(msg) + if not ok: + return 1 + + data = generate_report_data(project_root, period=period, model=model) + + if json_output: + print(json.dumps(data, indent=2)) + else: + print(format_terminal_report(data, by_tool=by_tool, endpoint=endpoint)) + + return 0 diff --git a/src/lineagelens/store/__init__.py b/src/lineagelens/store/__init__.py index d0fd37f..c19c229 100644 --- a/src/lineagelens/store/__init__.py +++ b/src/lineagelens/store/__init__.py @@ -9,13 +9,17 @@ from __future__ import annotations from .db import DB_FILENAME, GraphNotFound, GraphStore, SchemaMismatch +from .metrics_store import METRICS_DB_FILENAME, MetricRecord, MetricsStore from .schema import RECURSION_LIMIT, SCHEMA_DDL __all__ = [ "DB_FILENAME", + "METRICS_DB_FILENAME", "RECURSION_LIMIT", "SCHEMA_DDL", "GraphNotFound", "GraphStore", + "MetricRecord", + "MetricsStore", "SchemaMismatch", ] diff --git a/src/lineagelens/store/metrics_store.py b/src/lineagelens/store/metrics_store.py new file mode 100644 index 0000000..3073c4b --- /dev/null +++ b/src/lineagelens/store/metrics_store.py @@ -0,0 +1,289 @@ +"""Local SQLite metrics persistence for MCP tool invocations and token savings. + +Stores invocation telemetry, counterfactual baseline tokens, optimized tokens, +and cloud synchronization state in `.lineagelens/metrics.sqlite`. +""" + +from __future__ import annotations + +import logging +import sqlite3 +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +METRICS_DB_FILENAME = "metrics.sqlite" + +METRICS_SCHEMA_DDL = """ +PRAGMA journal_mode = WAL; +PRAGMA foreign_keys = ON; +PRAGMA synchronous = NORMAL; + +CREATE TABLE IF NOT EXISTS mcp_invocations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + tool_name TEXT NOT NULL, + query_type TEXT NOT NULL, + raw_tokens INTEGER NOT NULL, + optimized_tokens INTEGER NOT NULL, + tokens_saved INTEGER NOT NULL, + duration_ms INTEGER NOT NULL, + response_bytes INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'ok', + error_message TEXT, + intent TEXT, + result_count INTEGER DEFAULT 0, + truncated INTEGER DEFAULT 0, + repo_name TEXT NOT NULL, + branch TEXT, + commit_sha TEXT, + model_name TEXT NOT NULL DEFAULT 'gpt-4o', + synced_to_cloud INTEGER NOT NULL DEFAULT 0, + synced_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_mcp_invocations_timestamp ON mcp_invocations(timestamp); +CREATE INDEX IF NOT EXISTS idx_mcp_invocations_tool ON mcp_invocations(tool_name); +CREATE INDEX IF NOT EXISTS idx_mcp_invocations_repo ON mcp_invocations(repo_name); +CREATE INDEX IF NOT EXISTS idx_mcp_invocations_sync ON mcp_invocations(synced_to_cloud); +""" + +PRICING_MODELS: dict[str, float] = { + "gpt-4o": 5.00, + "claude-3-5-sonnet": 6.00, + "o1": 10.00, + "o3-mini": 10.00, + "deepseek-r1": 1.00, + "deepseek-v3": 1.00, +} + + +@dataclass(slots=True) +class MetricRecord: + tool_name: str + query_type: str + raw_tokens: int + optimized_tokens: int + tokens_saved: int + duration_ms: int + response_bytes: int + repo_name: str + timestamp: str | None = None + status: str = "ok" + error_message: str | None = None + intent: str | None = None + result_count: int = 0 + truncated: int = 0 + branch: str | None = None + commit_sha: str | None = None + model_name: str = "gpt-4o" + synced_to_cloud: int = 0 + synced_at: str | None = None + + +class MetricsStore: + """Manages the metrics SQLite database.""" + + def __init__(self, db_path: Path) -> None: + self.db_path = Path(db_path).resolve() + self._init_db() + + @classmethod + def for_project(cls, project_root: Path) -> MetricsStore: + db_path = Path(project_root).resolve() / ".lineagelens" / METRICS_DB_FILENAME + return cls(db_path) + + def _get_connection(self) -> sqlite3.Connection: + self.db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect( + str(self.db_path), + timeout=10.0, + check_same_thread=False, + isolation_level=None, # autocommit mode + ) + conn.row_factory = sqlite3.Row + return conn + + def _init_db(self) -> None: + with self._get_connection() as conn: + conn.executescript(METRICS_SCHEMA_DDL) + + def record(self, record: MetricRecord) -> int: + """Insert a single invocation record.""" + ts = record.timestamp or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + with self._get_connection() as conn: + cur = conn.execute( + """ + INSERT INTO mcp_invocations ( + timestamp, tool_name, query_type, raw_tokens, optimized_tokens, + tokens_saved, duration_ms, response_bytes, status, error_message, + intent, result_count, truncated, repo_name, branch, commit_sha, + model_name, synced_to_cloud, synced_at + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ) + """, + ( + ts, + record.tool_name, + record.query_type, + record.raw_tokens, + record.optimized_tokens, + record.tokens_saved, + record.duration_ms, + record.response_bytes, + record.status, + record.error_message, + record.intent, + record.result_count, + 1 if record.truncated else 0, + record.repo_name, + record.branch, + record.commit_sha, + record.model_name, + record.synced_to_cloud, + record.synced_at, + ), + ) + return cur.lastrowid or 0 + + def _period_clause(self, period: str) -> tuple[str, list[Any]]: + clauses = { + "today": "WHERE timestamp >= datetime('now', 'start of day')", + "7d": "WHERE timestamp >= datetime('now', '-7 days')", + "30d": "WHERE timestamp >= datetime('now', '-30 days')", + } + return clauses.get(period, ""), [] + + def get_summary(self, period: str = "all", model_name: str = "gpt-4o") -> dict[str, Any]: + """Aggregate totals across the specified time period.""" + clause, params = self._period_clause(period) + sql = f""" + SELECT + COUNT(*) as total_invocations, + COALESCE(SUM(raw_tokens), 0) as raw_tokens, + COALESCE(SUM(optimized_tokens), 0) as optimized_tokens, + COALESCE(SUM(tokens_saved), 0) as tokens_saved, + COALESCE(AVG(duration_ms), 0.0) as avg_duration_ms + FROM mcp_invocations + {clause} + """ # noqa: S608 + with self._get_connection() as conn: + row = conn.execute(sql, params).fetchone() + total_invocations = int(row["total_invocations"]) + raw_tokens = int(row["raw_tokens"]) + optimized_tokens = int(row["optimized_tokens"]) + tokens_saved = int(row["tokens_saved"]) + avg_duration_ms = round(float(row["avg_duration_ms"]), 1) + + reduction_percentage = ( + round((tokens_saved / raw_tokens) * 100.0, 2) if raw_tokens > 0 else 0.0 + ) + rate = PRICING_MODELS.get(model_name, PRICING_MODELS["gpt-4o"]) + cost_savings_usd = round((tokens_saved / 1_000_000) * rate, 2) + + return { + "total_invocations": total_invocations, + "raw_tokens": raw_tokens, + "optimized_tokens": optimized_tokens, + "tokens_saved": tokens_saved, + "reduction_percentage": reduction_percentage, + "cost_savings_usd": cost_savings_usd, + "model_name": model_name, + "avg_duration_ms": avg_duration_ms, + } + + def get_by_tool(self, period: str = "all") -> dict[str, dict[str, Any]]: + """Tool-by-tool breakdown across the specified time period.""" + clause, params = self._period_clause(period) + sql = f""" + SELECT + tool_name, + COUNT(*) as calls, + COALESCE(SUM(raw_tokens), 0) as raw_tokens, + COALESCE(SUM(optimized_tokens), 0) as optimized_tokens, + COALESCE(SUM(tokens_saved), 0) as tokens_saved, + COALESCE(AVG(duration_ms), 0.0) as avg_duration_ms + FROM mcp_invocations + {clause} + GROUP BY tool_name + ORDER BY calls DESC, tool_name ASC + """ # noqa: S608 + results: dict[str, dict[str, Any]] = {} + with self._get_connection() as conn: + for row in conn.execute(sql, params): + raw = int(row["raw_tokens"]) + opt = int(row["optimized_tokens"]) + saved = int(row["tokens_saved"]) + red = round((saved / raw) * 100.0, 2) if raw > 0 else 0.0 + results[row["tool_name"]] = { + "calls": int(row["calls"]), + "raw_tokens": raw, + "optimized_tokens": opt, + "tokens_saved": saved, + "reduction_percentage": red, + "avg_duration_ms": round(float(row["avg_duration_ms"]), 1), + } + return results + + def get_cloud_sync_status(self) -> dict[str, Any]: + """Status of synced vs unsynced records.""" + with self._get_connection() as conn: + row = conn.execute( + """ + SELECT + COUNT(*) as total, + COALESCE(SUM(CASE WHEN synced_to_cloud = 1 THEN 1 ELSE 0 END), 0) as synced, + COALESCE(SUM(CASE WHEN synced_to_cloud = 0 THEN 1 ELSE 0 END), 0) as pending, + MAX(CASE WHEN synced_to_cloud = 1 THEN synced_at ELSE NULL END) as last_synced_at + FROM mcp_invocations + """ + ).fetchone() + return { + "total": int(row["total"]), + "synced": int(row["synced"]), + "pending": int(row["pending"]), + "last_synced_at": row["last_synced_at"], + } + + def get_unsynced(self, limit: int = 100) -> list[dict[str, Any]]: + """Retrieve unsynced records up to limit.""" + with self._get_connection() as conn: + rows = conn.execute( + """ + SELECT + id, query_type, raw_tokens, optimized_tokens, tokens_saved, + duration_ms, repo_name, branch, commit_sha, model_name, timestamp + FROM mcp_invocations + WHERE synced_to_cloud = 0 + ORDER BY id ASC + LIMIT ? + """, + (limit,), + ).fetchall() + return [dict(r) for r in rows] + + def mark_synced(self, ids: list[int], synced_at: str | None = None) -> None: + """Mark invocation IDs as successfully synced to cloud.""" + if not ids: + return + ts = synced_at or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + placeholders = ",".join("?" for _ in ids) + with self._get_connection() as conn: + conn.execute( + f""" + UPDATE mcp_invocations + SET synced_to_cloud = 1, synced_at = ? + WHERE id IN ({placeholders}) + """, # noqa: S608 + [ts, *ids], + ) + + def reset(self) -> None: + """Delete all metrics history.""" + with self._get_connection() as conn: + conn.execute("DELETE FROM mcp_invocations") + conn.execute("VACUUM") diff --git a/src/lineagelens/sync.py b/src/lineagelens/sync.py new file mode 100644 index 0000000..d31a45a --- /dev/null +++ b/src/lineagelens/sync.py @@ -0,0 +1,141 @@ +"""ContextServe.ai Cloud Reporting & Synchronization (#mcp-metrics). + +Syncs local SQLite metrics from .lineagelens/metrics.sqlite to ContextServe.ai. +""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path + +from .credentials import CredentialsStore +from .store.metrics_store import MetricsStore + +logger = logging.getLogger(__name__) + +DEFAULT_CLOUD_ENDPOINT = "https://contextserve.ai" +BATCH_SYNC_PATH = "/api/v1/telemetry/tokens/batch" +SINGLE_SYNC_PATH = "/api/v1/telemetry/tokens" + + +def get_auth_token_and_base_url(custom_endpoint: str | None = None) -> tuple[str | None, str, str | None]: + """Retrieve auth token, base URL, and org from env or credentials store. + + Returns: + (token, base_url, org) + """ + token = os.environ.get("CONTEXTSERVE_API_KEY") or os.environ.get("LINEAGELENS_API_TOKEN") + base_url = custom_endpoint or os.environ.get("CONTEXTSERVE_BASE_URL") + org = None + + store = CredentialsStore() + active_env = store.active_env + creds = store.get(active_env) or {} + + if not token and creds: + token = creds.get("access_token") + if not base_url: + base_url = creds.get("base_url") or DEFAULT_CLOUD_ENDPOINT + if creds: + org = creds.get("organization") or creds.get("org") + + return token, base_url.rstrip("/"), org + + +def sync_metrics( + project_root: Path, + endpoint: str | None = None, + batch_size: int = 100, +) -> tuple[bool, str, int]: + """Upload pending local invocation records to ContextServe.ai. + + Returns: + (success, message, count_synced) + """ + import httpx + + token, base_url, org = get_auth_token_and_base_url(endpoint) + if not token: + msg = ( + "Not authenticated to ContextServe.ai.\n" + "Run `lineagelens auth login` or set `CONTEXTSERVE_API_KEY=` to sync metrics." + ) + return False, msg, 0 + + store = MetricsStore.for_project(project_root) + total_synced = 0 + + headers = {"Content-Type": "application/json"} + if token.startswith("ll_live_"): + headers["X-API-Key"] = token + else: + headers["Authorization"] = f"Bearer {token}" + headers["X-Auth-Token"] = f"Bearer {token}" + + while True: + pending = store.get_unsynced(limit=batch_size) + if not pending: + break + + payload_events = [] + ids = [] + for r in pending: + ids.append(r["id"]) + payload_events.append({ + "query_type": r["query_type"], + "raw_tokens": r["raw_tokens"], + "optimized_tokens": r["optimized_tokens"], + "tokens_saved": r["tokens_saved"], + "duration_ms": r["duration_ms"], + "repo_name": r["repo_name"], + "branch": r.get("branch"), + "commit_sha": r.get("commit_sha"), + "model_name": r.get("model_name", "gpt-4o"), + "timestamp": r["timestamp"], + }) + + batch_payload = {"events": payload_events} + + try: + with httpx.Client(timeout=10.0) as client: + res = client.post( + f"{base_url}{BATCH_SYNC_PATH}", + json=batch_payload, + headers=headers, + ) + if res.status_code in (404, 405): + # Fallback to single item endpoint if batch route is not present + for event in payload_events: + res = client.post( + f"{base_url}{SINGLE_SYNC_PATH}", + json=event, + headers=headers, + ) + if res.status_code >= 400: + return ( + False, + f"Failed to sync metric to {base_url}: HTTP {res.status_code} {res.text}", + total_synced, + ) + elif res.status_code >= 400: + return ( + False, + f"Failed to sync batch to {base_url}: HTTP {res.status_code} {res.text}", + total_synced, + ) + + store.mark_synced(ids) + total_synced += len(ids) + + except Exception as exc: + logger.debug("Sync failed with exception", exc_info=True) + return False, f"Network or server error during sync: {exc}", total_synced + + repo_name = project_root.resolve().name + org_suffix = f", org: {org}" if org else "" + return ( + True, + f"Successfully reported {total_synced} metrics to {base_url} (repo: {repo_name}{org_suffix}).", + total_synced, + ) diff --git a/tests/test_metrics_store.py b/tests/test_metrics_store.py new file mode 100644 index 0000000..ac5def7 --- /dev/null +++ b/tests/test_metrics_store.py @@ -0,0 +1,189 @@ +"""Tests for MetricsStore (.lineagelens/metrics.sqlite).""" + +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import pytest + +from lineagelens.store.metrics_store import ( + PRICING_MODELS, + MetricRecord, + MetricsStore, +) + + +@pytest.fixture +def store(tmp_path: Path) -> MetricsStore: + db_file = tmp_path / ".lineagelens" / "metrics.sqlite" + return MetricsStore(db_file) + + +def test_metrics_store_initialization(store: MetricsStore) -> None: + assert store.db_path.exists() + status = store.get_cloud_sync_status() + assert status["total"] == 0 + assert status["synced"] == 0 + assert status["pending"] == 0 + assert status["last_synced_at"] is None + + +def test_record_single_invocation(store: MetricsStore) -> None: + record = MetricRecord( + tool_name="find_paths", + query_type="mcp_find_paths", + raw_tokens=14200, + optimized_tokens=420, + tokens_saved=13780, + duration_ms=18, + response_bytes=1680, + repo_name="LineageLens", + branch="main", + commit_sha="abcdef123456", + model_name="gpt-4o", + ) + row_id = store.record(record) + assert row_id > 0 + + summary = store.get_summary(period="all", model_name="gpt-4o") + assert summary["total_invocations"] == 1 + assert summary["raw_tokens"] == 14200 + assert summary["optimized_tokens"] == 420 + assert summary["tokens_saved"] == 13780 + assert summary["reduction_percentage"] == round((13780 / 14200) * 100, 2) + assert summary["cost_savings_usd"] == round((13780 / 1_000_000) * 5.00, 2) + assert summary["avg_duration_ms"] == 18.0 + + by_tool = store.get_by_tool(period="all") + assert "find_paths" in by_tool + assert by_tool["find_paths"]["calls"] == 1 + assert by_tool["find_paths"]["tokens_saved"] == 13780 + + +def test_concurrent_writes(store: MetricsStore) -> None: + def write_call(i: int) -> int: + rec = MetricRecord( + tool_name="impact_of", + query_type="mcp_impact_of", + raw_tokens=2000 + i, + optimized_tokens=200, + tokens_saved=1800 + i, + duration_ms=25, + response_bytes=800, + repo_name="LineageLens", + ) + return store.record(rec) + + with ThreadPoolExecutor(max_workers=8) as pool: + results = list(pool.map(write_call, range(50))) + + assert len(results) == 50 + summary = store.get_summary() + assert summary["total_invocations"] == 50 + + +def test_pricing_models(store: MetricsStore) -> None: + rec = MetricRecord( + tool_name="explain", + query_type="mcp_explain", + raw_tokens=1_000_000, + optimized_tokens=0, + tokens_saved=1_000_000, + duration_ms=10, + response_bytes=0, + repo_name="LineageLens", + ) + store.record(rec) + + for model, rate in PRICING_MODELS.items(): + summary = store.get_summary(model_name=model) + assert summary["cost_savings_usd"] == round(rate, 2) + + +def test_sync_state_and_mark_synced(store: MetricsStore) -> None: + id1 = store.record(MetricRecord( + tool_name="callers_of", + query_type="mcp_callers_of", + raw_tokens=3000, + optimized_tokens=300, + tokens_saved=2700, + duration_ms=15, + response_bytes=1200, + repo_name="LineageLens", + )) + id2 = store.record(MetricRecord( + tool_name="callees_of", + query_type="mcp_callees_of", + raw_tokens=4000, + optimized_tokens=400, + tokens_saved=3600, + duration_ms=12, + response_bytes=1600, + repo_name="LineageLens", + )) + + status = store.get_cloud_sync_status() + assert status["total"] == 2 + assert status["synced"] == 0 + assert status["pending"] == 2 + + unsynced = store.get_unsynced(limit=10) + assert len(unsynced) == 2 + assert unsynced[0]["id"] == id1 + assert unsynced[1]["id"] == id2 + + store.mark_synced([id1]) + status2 = store.get_cloud_sync_status() + assert status2["synced"] == 1 + assert status2["pending"] == 1 + assert status2["last_synced_at"] is not None + + unsynced2 = store.get_unsynced(limit=10) + assert len(unsynced2) == 1 + assert unsynced2[0]["id"] == id2 + + +def test_reset_store(store: MetricsStore) -> None: + store.record(MetricRecord( + tool_name="explore", + query_type="mcp_explore", + raw_tokens=5000, + optimized_tokens=500, + tokens_saved=4500, + duration_ms=20, + response_bytes=2000, + repo_name="LineageLens", + )) + assert store.get_summary()["total_invocations"] == 1 + store.reset() + assert store.get_summary()["total_invocations"] == 0 + + +def test_metrics_persist_across_graph_rebuild(tmp_path: Path) -> None: + from lineagelens.store import GraphStore + + project = tmp_path / "app" + project.mkdir() + metrics_store = MetricsStore.for_project(project) + metrics_store.record(MetricRecord( + tool_name="find_paths", + query_type="mcp_find_paths", + raw_tokens=1000, + optimized_tokens=100, + tokens_saved=900, + duration_ms=15, + response_bytes=400, + repo_name="app", + )) + assert metrics_store.get_summary()["total_invocations"] == 1 + + # GraphStore creation and rebuild in same project folder + graph_db = project / ".lineagelens" / "graph.sqlite" + with GraphStore.create(graph_db, project_root=str(project)): + pass + assert metrics_store.get_summary()["total_invocations"] == 1 + + # Force rebuild + with GraphStore.create(graph_db, project_root=str(project), overwrite=True): + pass + assert metrics_store.get_summary()["total_invocations"] == 1 + diff --git a/tests/test_report.py b/tests/test_report.py new file mode 100644 index 0000000..6aba8d0 --- /dev/null +++ b/tests/test_report.py @@ -0,0 +1,94 @@ +"""Tests for report generation and CLI report subcommand.""" + +import json +from pathlib import Path + +import pytest + +from lineagelens.cli import main +from lineagelens.store.metrics_store import MetricRecord, MetricsStore + + +@pytest.fixture +def project_with_metrics(tmp_path: Path) -> Path: + proj = tmp_path / "repo" + proj.mkdir() + store = MetricsStore.for_project(proj) + store.record(MetricRecord( + tool_name="find_paths", + query_type="mcp_find_paths", + raw_tokens=542000, + optimized_tokens=12400, + tokens_saved=529600, + duration_ms=19, + response_bytes=49600, + repo_name="repo", + branch="main", + )) + store.record(MetricRecord( + tool_name="impact_of", + query_type="mcp_impact_of", + raw_tokens=480000, + optimized_tokens=9800, + tokens_saved=470200, + duration_ms=31, + response_bytes=39200, + repo_name="repo", + branch="main", + )) + return proj + + +def test_cli_report_json(project_with_metrics: Path, capsys: pytest.CaptureFixture[str]) -> None: + code = main(["report", str(project_with_metrics), "--json"]) + assert code == 0 + + captured = capsys.readouterr() + data = json.loads(captured.out) + assert data["repo_name"] == "repo" + assert data["summary"]["total_invocations"] == 2 + assert data["summary"]["raw_tokens"] == 1022000 + assert data["summary"]["optimized_tokens"] == 22200 + assert data["summary"]["tokens_saved"] == 999800 + assert "find_paths" in data["by_tool"] + assert "impact_of" in data["by_tool"] + + +def test_cli_report_terminal_table( + project_with_metrics: Path, capsys: pytest.CaptureFixture[str] +) -> None: + code = main(["report", str(project_with_metrics), "--by-tool"]) + assert code == 0 + + captured = capsys.readouterr() + assert "LINEAGELENS MCP TOKEN SAVINGS REPORT" in captured.out + assert "TOTAL INVOCATIONS" in captured.out + assert "TOOL BREAKDOWN" in captured.out + assert "find_paths" in captured.out + assert "impact_of" in captured.out + assert "ESTIMATED COST SAVINGS" in captured.out + + +def test_cli_report_reset_prompt_cancel( + project_with_metrics: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr("builtins.input", lambda _: "n") + code = main(["report", str(project_with_metrics), "--reset"]) + assert code == 0 + captured = capsys.readouterr() + assert "Reset cancelled." in captured.out + + store = MetricsStore.for_project(project_with_metrics) + assert store.get_summary()["total_invocations"] == 2 + + +def test_cli_report_reset_with_yes( + project_with_metrics: Path, capsys: pytest.CaptureFixture[str] +) -> None: + code = main(["report", str(project_with_metrics), "--reset", "-y"]) + assert code == 0 + captured = capsys.readouterr() + assert "Local metrics history reset successfully." in captured.out + + store = MetricsStore.for_project(project_with_metrics) + assert store.get_summary()["total_invocations"] == 0 diff --git a/tests/test_sync.py b/tests/test_sync.py new file mode 100644 index 0000000..98e862d --- /dev/null +++ b/tests/test_sync.py @@ -0,0 +1,89 @@ +"""Tests for metrics synchronization to ContextServe.ai (lineagelens.sync).""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from lineagelens.store.metrics_store import MetricRecord, MetricsStore +from lineagelens.sync import sync_metrics + + +@pytest.fixture +def project(tmp_path: Path) -> Path: + proj = tmp_path / "my_project" + proj.mkdir() + store = MetricsStore.for_project(proj) + store.record(MetricRecord( + tool_name="find_paths", + query_type="mcp_find_paths", + raw_tokens=10000, + optimized_tokens=500, + tokens_saved=9500, + duration_ms=22, + response_bytes=2000, + repo_name="my_project", + branch="main", + commit_sha="c0ffee", + )) + return proj + + +def test_sync_unauthenticated(project: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("CONTEXTSERVE_API_KEY", raising=False) + monkeypatch.delenv("LINEAGELENS_API_TOKEN", raising=False) + + with patch("lineagelens.credentials.CredentialsStore.get", return_value={}): + ok, msg, count = sync_metrics(project) + + assert not ok + assert "Not authenticated to ContextServe.ai" in msg + assert count == 0 + + +def test_sync_batch_success(project: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CONTEXTSERVE_API_KEY", "ll_live_test_key_123") + + mock_response = MagicMock() + mock_response.status_code = 200 + + with patch("httpx.Client.post", return_value=mock_response) as mock_post: + ok, msg, count = sync_metrics(project, endpoint="https://test.contextserve.ai") + + assert ok + assert count == 1 + assert "Successfully reported 1 metrics" in msg + + # Verify headers and payload + mock_post.assert_called_once() + args, kwargs = mock_post.call_args + assert args[0] == "https://test.contextserve.ai/api/v1/telemetry/tokens/batch" + assert kwargs["headers"]["X-API-Key"] == "ll_live_test_key_123" + assert len(kwargs["json"]["events"]) == 1 + + # Verify SQLite row was marked synced + store = MetricsStore.for_project(project) + status = store.get_cloud_sync_status() + assert status["synced"] == 1 + assert status["pending"] == 0 + + +def test_sync_fallback_to_single_endpoint(project: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CONTEXTSERVE_API_KEY", "jwt_token_456") + + res_404 = MagicMock(status_code=404) + res_200 = MagicMock(status_code=200) + + # First call (/batch) returns 404, fallback call (/tokens) returns 200 + with patch("httpx.Client.post", side_effect=[res_404, res_200]) as mock_post: + ok, _, count = sync_metrics(project, endpoint="https://test.contextserve.ai") + + assert ok + assert count == 1 + assert mock_post.call_count == 2 + second_call_url = mock_post.call_args_list[1][0][0] + assert second_call_url == "https://test.contextserve.ai/api/v1/telemetry/tokens" + + store = MetricsStore.for_project(project) + status = store.get_cloud_sync_status() + assert status["synced"] == 1 From 32209ef217ee91361b756e9603e63512b6be748f Mon Sep 17 00:00:00 2001 From: Ram Viswanadha Date: Sun, 27 Sep 2026 22:12:13 -0700 Subject: [PATCH 3/6] add spec file --- .vscode/mcp.json | 15 + docs/mcp-metrics-and-token-savings-spec.md | 323 +++++++++++++++++++++ 2 files changed, 338 insertions(+) create mode 100644 .vscode/mcp.json create mode 100644 docs/mcp-metrics-and-token-savings-spec.md diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 0000000..e7f8c3d --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,15 @@ +{ + "servers": { + "lineagelens": { + "command": "docker", + "args": [ + "exec", + "-i", + "lineagelens", + "lineagelens", + "mcp", + "/workspace" + ] + } + } +} diff --git a/docs/mcp-metrics-and-token-savings-spec.md b/docs/mcp-metrics-and-token-savings-spec.md new file mode 100644 index 0000000..ad6d046 --- /dev/null +++ b/docs/mcp-metrics-and-token-savings-spec.md @@ -0,0 +1,323 @@ +# LineageLens Local SQLite Metrics & MCP Token Savings Reporter Spec + +## 1. Executive Summary + +LineageLens provides deterministic, compiler-grade code intelligence that eliminates the need for LLMs to read entire source files into their context windows. Currently, invocation metrics and token telemetry are only transmitted upstream to ContextServe.ai when a developer is authenticated with an API key. When running unauthenticated or offline, these metrics are discarded. + +This specification details the architecture for: +1. **Local SQLite Metrics Persistence**: Recording all MCP tool executions, token metrics, latency, and query provenance locally into `.lineagelens/metrics.sqlite`. +2. **Ground-Truth Token Savings Calculation**: Deterministically calculating the counterfactual baseline (`raw_tokens` that an LLM agent would have spent loading full source files into context) versus the surgical LineageLens response (`optimized_tokens`). +3. **CLI Reporting Command (`lineagelens report`)**: Providing developers and engineering teams with an interactive and machine-readable (JSON) summary of MCP tool usage, net tokens saved, context reduction percentage, and estimated dollar savings. +4. **Privacy & Offline Guarantee**: 100% local, zero network egress required, zero latency overhead on tool execution. + +--- + +## 2. Problem Statement & Motivation + +### Current Limitations +- **Metrics Dropped When Unauthenticated**: In `src/lineagelens/mcp/server.py`, `_send_telemetry` checks `if not token: return`. Developers evaluating LineageLens locally via Hackernews or open-source tools have zero visibility into their actual token savings. +- **Graph Rebuild Risk**: In `src/lineagelens/store/db.py`, `GraphStore.create()` unlinks `graph.sqlite` on rebuilds (`lineagelens index . --force`). Any metrics stored in `graph.sqlite` would be erased whenever code changes are re-indexed. +- **No Local ROI Reporting**: Developers using Cursor, Claude Desktop, Antigravity, or Codex cannot inspect how many tokens LineageLens saved during their coding sessions without logging into ContextServe.ai's web dashboard. + +--- + +## 3. Database Architecture: `.lineagelens/metrics.sqlite` + +### 3.1 Separate Database Strategy +Metrics are stored in `.lineagelens/metrics.sqlite` (separate from `.lineagelens/graph.sqlite`): +- **Persistence Across Reindexes**: Code indexing runs frequently as developers write code. Metrics are an append-only time series of tool invocations that must outlive code graph rebuilds. +- **Schema Isolation**: The code graph is pinned to `SCHEMA_VERSION = 4` with strict checksum verification. Metrics have an independent, lightweight lifecycle. +- **Concurrency & WAL Mode**: MCP tools run concurrently across multiple threads/processes. WAL mode (`PRAGMA journal_mode = WAL`) guarantees non-blocking concurrent writes with negligible latency (< 0.5ms). + +### 3.2 Database Schema (DDL) + +```sql +PRAGMA journal_mode = WAL; +PRAGMA foreign_keys = ON; +PRAGMA synchronous = NORMAL; + +CREATE TABLE IF NOT EXISTS mcp_invocations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, -- ISO-8601 UTC string (e.g. '2026-09-27T21:30:00Z') + tool_name TEXT NOT NULL, -- e.g. 'find_paths', 'impact_of', 'callers_of' + query_type TEXT NOT NULL, -- e.g. 'mcp_find_paths' (ContextServe.ai contract) + raw_tokens INTEGER NOT NULL, -- Counterfactual baseline tokens without LineageLens + optimized_tokens INTEGER NOT NULL, -- Actual serialized response tokens sent to LLM + tokens_saved INTEGER NOT NULL, -- max(0, raw_tokens - optimized_tokens) + duration_ms INTEGER NOT NULL, -- Execution latency in milliseconds + response_bytes INTEGER NOT NULL, -- Byte size of JSON response payload + status TEXT NOT NULL DEFAULT 'ok', -- 'ok' | 'error' + error_message TEXT, -- Exception message if status == 'error' + intent TEXT, -- Agent intent if specified (e.g. 'precise') + result_count INTEGER DEFAULT 0, -- Number of items returned in 'results' + truncated INTEGER DEFAULT 0, -- 1 if result set was capped + repo_name TEXT NOT NULL, -- Basename of indexed project + branch TEXT, -- Git branch at execution time + commit_sha TEXT, -- Git commit at execution time + model_name TEXT NOT NULL DEFAULT 'gpt-4o', -- Target model for pricing baseline + synced_to_cloud INTEGER NOT NULL DEFAULT 0, -- 1 if transmitted upstream to ContextServe.ai + synced_at TEXT -- ISO-8601 timestamp of successful cloud sync +); + +CREATE INDEX IF NOT EXISTS idx_mcp_invocations_timestamp ON mcp_invocations(timestamp); +CREATE INDEX IF NOT EXISTS idx_mcp_invocations_tool ON mcp_invocations(tool_name); +CREATE INDEX IF NOT EXISTS idx_mcp_invocations_repo ON mcp_invocations(repo_name); +CREATE INDEX IF NOT EXISTS idx_mcp_invocations_sync ON mcp_invocations(synced_to_cloud); +``` + +--- + +## 4. Deterministic Token Savings Calculation + +A core requirement is avoiding arbitrary or fabricated numbers. LineageLens bases its calculations on verifiable, compiler-grade facts. + +### 4.1 Ground-Truth Counterfactual (`raw_tokens`) +When an AI agent uses generic file exploration instead of LineageLens, it must read entire source files into its context window to discover call chains, blast radii, or data flows. + +1. **Graph-Attributed File Size Calculation**: + - During the execution of tools such as `find_paths`, `callers_of`, `callees_of`, `impact_of`, `explain`, `dataflow_of`, and `get_symbol`, the query engine touches specific nodes. + - LineageLens queries its own `files` table in `graph.sqlite` to find the set of distinct files containing those nodes: + ```sql + SELECT DISTINCT f.size_bytes + FROM files f + JOIN nodes n ON n.file_id = f.id + WHERE n.id IN (:touched_node_ids); + ``` + - Counterfactual tokens for those source files: + $$\text{raw\_tokens} = \sum \left\lfloor \frac{\text{file.size\_bytes}}{4} \right\rfloor$$ +2. **Conservative Fallback / Baseline Floor**: + - For tools that do not resolve specific node IDs (or return 0 results), an agent would still read at least the immediate file or run a search scan. + - Floor: + $$\text{raw\_tokens} = \max(\text{raw\_tokens}, \text{optimized\_tokens} \times 8, 1200)$$ + *(1,200 tokens corresponds to ~300 lines of standard source code, a conservative baseline for one source file).* + +### 4.2 Optimized Tokens (`optimized_tokens`) +- Measured directly on the serialized wire payload returned to the agent: + $$\text{optimized\_tokens} = \max\left(1, \left\lfloor \frac{\text{response\_bytes}}{4} \right\rfloor\right)$$ + *(Matches the project's canonical constant `BYTES_PER_TOKEN = 4` in `src/lineagelens/telemetry.py`).* + +### 4.3 Metrics Formulas +- **Tokens Saved**: $\text{tokens\_saved} = \max(0, \text{raw\_tokens} - \text{optimized\_tokens})$ +- **Context Reduction Ratio**: $\text{reduction\_pct} = \frac{\text{tokens\_saved}}{\text{raw\_tokens}} \times 100\%$ +- **Estimated Cost Savings ($ USD)**: + $$\text{cost\_saved} = \left(\frac{\text{tokens\_saved}}{1{,}000{,}000}\right) \times \text{model\_rate\_per\_1M}$$ + +#### Default Pricing Model Table ($ per 1M Input Tokens) +| Model Identifier | Input Rate / 1M | Output Rate / 1M | Blended Rate Used | +| :--- | :--- | :--- | :--- | +| `gpt-4o` *(default)* | $2.50 | $10.00 | **$5.00** | +| `claude-3-5-sonnet` | $3.00 | $15.00 | **$6.00** | +| `o1` / `o3-mini` | $5.00 | $20.00 | **$10.00** | +| `deepseek-r1` / `v3` | $0.55 | $2.19 | **$1.00** | + +--- + +## 5. Execution Pipeline: Zero-Overhead Interception + +### 5.1 Interceptor in `src/lineagelens/mcp/server.py` +In `create_server()`, the `@guarded` decorator intercepts every tool invocation: + +```python +# Within guarded wrapper: +with telemetry.timed() as clock: + try: + result = await fn(*args, **kwargs) + status = "ok" + error_msg = None + except Exception as exc: + status = "error" + error_msg = str(exc) + ... + +response_bytes = _response_bytes(result) +raw_tokens, optimized_tokens = compute_token_impact(engine, fn.__name__, result, response_bytes) + +# 1. Non-blocking asynchronous / background local SQLite write: +record_local_metric( + db_path=project / ".lineagelens" / "metrics.sqlite", + tool_name=fn.__name__, + raw_tokens=raw_tokens, + optimized_tokens=optimized_tokens, + duration_ms=clock.ms, + response_bytes=response_bytes, + repo_name=project.name, + branch=branch, + commit_sha=commit_sha, + status=status, + error_message=error_msg, + intent=kwargs.get("intent"), + result_count=len(result.get("results", ())) if isinstance(result, dict) else 0, +) + +# 2. Live telemetry sync if authenticated & opted in: +if is_cloud_telemetry_enabled(): + _send_telemetry(fn.__name__, raw_tokens, optimized_tokens) +``` + +--- + +## 6. ContextServe.ai Cloud Reporting & Synchronization + +Developers and organizations who want centralized visibility can report their local SQLite metrics back to **ContextServe.ai** on-demand or automatically. + +### 6.1 Synchronization Mechanism +When synchronization is triggered (via `lineagelens report --sync`): +1. **Authentication Check**: + - Checks for active session via `CredentialsStore` (from `lineagelens auth login`) or `CONTEXTSERVE_API_KEY` / `LINEAGELENS_API_TOKEN` environment variables. + - If missing, prints clear instructions: + ```text + Not authenticated to ContextServe.ai. + Run `lineagelens auth login` or set `CONTEXTSERVE_API_KEY=` to sync metrics. + ``` +2. **Batch Ingestion**: + - Queries all rows in `.lineagelens/metrics.sqlite` where `synced_to_cloud = 0`. + - Chunks payloads in batches of up to 100 records and posts to: + `POST /api/v1/telemetry/tokens/batch` (or sequentially to `/api/v1/telemetry/tokens`). + - Payload matches the upstream ingestion contract: + ```json + { + "events": [ + { + "query_type": "mcp_find_paths", + "raw_tokens": 14200, + "optimized_tokens": 420, + "tokens_saved": 13780, + "duration_ms": 18, + "repo_name": "LineageLens", + "branch": "main", + "commit_sha": "a1b2c3d4e5f6", + "model_name": "gpt-4o", + "timestamp": "2026-09-27T21:15:32Z" + } + ] + } + ``` +3. **Local State Update**: + - On HTTP 200/201 acknowledgment from the server, updates local rows: + ```sql + UPDATE mcp_invocations + SET synced_to_cloud = 1, synced_at = datetime('now') + WHERE id IN (:synced_ids); + ``` + - Unsynced records remain preserved locally in SQLite for retry if network or server is unavailable. + +--- + +## 7. CLI Command: `lineagelens report` + +### 7.1 Command Specification +Add `report` as a top-level subcommand in `src/lineagelens/cli.py`: + +```bash +lineagelens report [PATH] [OPTIONS] +``` + +#### Arguments & Options +- `PATH`: Path to project root (default: `.`) +- `--period [today|7d|30d|all]`: Filter report by time window (default: `all`) +- `--by-tool`: Display breakdown table for every individual MCP tool +- `--sync`: **Upload and report unsynced local metrics to ContextServe.ai** +- `--endpoint `: Override ContextServe.ai API endpoint (defaults to active environment or `https://contextserve.ai`) +- `--json`: Output raw JSON data for CI/CD assertions and automation +- `--model [gpt-4o|claude-3-5-sonnet|deepseek-r1]`: Model pricing baseline (default: `gpt-4o`) +- `--reset`: Wipe local metrics history (prompts confirmation unless `-y` is passed) + +### 7.2 Visual Output Format (Terminal UI) + +```text +======================================================================================== + LINEAGELENS MCP TOKEN SAVINGS REPORT + Repository: LineageLens (branch: main) + Period : Last 7 Days (2026-09-20 to 2026-09-27) + Target : gpt-4o ($5.00 / 1M blended tokens) + Cloud Sync: 142 / 142 synced to ContextServe.ai (org: ContextServe) +======================================================================================== + + TOTAL INVOCATIONS BASELINE TOKENS OPTIMIZED TOKENS NET TOKENS SAVED + 142 1,842,500 48,200 1,794,300 + (Unindexed Context) (LineageLens MCP) (97.4% Reduction) + + ESTIMATED COST SAVINGS: $8.97 USD + +TOOL BREAKDOWN: +Tool Invocations Raw Tokens Optimized Tokens Saved Reduction Avg Latency +------------------------------------------------------------------------------------------------ +find_paths 38 542,000 12,400 529,600 97.7% 19ms +impact_of 32 480,000 9,800 470,200 98.0% 31ms +callers_of 28 336,000 8,400 327,600 97.5% 14ms +callees_of 22 264,000 7,100 256,900 97.3% 12ms +dataflow_of 12 144,000 6,200 137,800 95.7% 26ms +explain 6 48,000 2,800 45,200 94.2% 16ms +explore 4 28,500 1,500 27,000 94.7% 21ms +------------------------------------------------------------------------------------------------ +TOTAL 142 1,842,500 48,200 1,794,300 97.4% 20ms +======================================================================================== +``` + +#### Sync Execution Output (`lineagelens report --sync`) +```text +Connecting to ContextServe.ai (environment: production)... +Found 48 unsynced invocation metrics. +Uploading batch 1/1 (48 records)... [OK] +Successfully reported 48 metrics to https://contextserve.ai (repo: LineageLens, org: ContextServe). +``` + +### 7.3 Machine-Readable JSON Output (`--json`) + +```json +{ + "repo_name": "LineageLens", + "branch": "main", + "period": "7d", + "cloud_sync": { + "total": 142, + "synced": 142, + "pending": 0, + "last_synced_at": "2026-09-27T21:18:02Z" + }, + "summary": { + "total_invocations": 142, + "raw_tokens": 1842500, + "optimized_tokens": 48200, + "tokens_saved": 1794300, + "reduction_percentage": 97.38, + "cost_savings_usd": 8.97, + "model_name": "gpt-4o", + "avg_duration_ms": 20.1 + }, + "by_tool": { + "find_paths": { + "calls": 38, + "raw_tokens": 542000, + "optimized_tokens": 12400, + "tokens_saved": 529600, + "reduction_percentage": 97.71, + "avg_duration_ms": 19.4 + } + } +} +``` + +--- + +## 8. Implementation Plan + +| Step | Component | Description | +| :--- | :--- | :--- | +| **1** | `src/lineagelens/store/metrics_store.py` | Create SQLite store for `.lineagelens/metrics.sqlite`, schema migrations, record insert, query aggregations. | +| **2** | `src/lineagelens/mcp/server.py` | Update `@guarded` decorator to calculate ground-truth `raw_tokens` from files touched and log to `MetricsStore`. | +| **3** | `src/lineagelens/sync.py` | Implement batch sync engine to report unsynced SQLite records upstream to `ContextServe.ai`. | +| **4** | `src/lineagelens/cli.py` & `report.py` | Add `lineagelens report` subcommand supporting `--period`, `--by-tool`, `--sync`, `--json`, `--model`, and `--reset`. | +| **5** | `tests/test_metrics_store.py` | Comprehensive test coverage: table creation, concurrent writes, time period filters, and token calculation accuracy. | +| **6** | `tests/test_sync.py` | Test batch upload to mock ContextServe HTTP endpoint and ensure `synced_to_cloud` transitions. | +| **7** | Documentation Updates | Document `lineagelens report` and `--sync` in `README.md`, `docs/TELEMETRY.md`, and ContextServe Docs component. | + +--- + +## 9. Verification & Acceptance Criteria + +1. **Offline & Non-Interfering**: Calling MCP tools with no network and no API key correctly logs rows to `.lineagelens/metrics.sqlite` with `synced_to_cloud = 0` without failing or slowing down the tool return. +2. **Re-index Safety**: Running `lineagelens index . --force` purges and rebuilds `graph.sqlite` while leaving `.lineagelens/metrics.sqlite` intact. +3. **Accuracy Verification**: `tokens_saved` matches `raw_tokens - optimized_tokens` across all queries; reduction percentage is mathematically verified. +4. **Cloud Reporting & Sync**: Running `lineagelens report --sync` pushes all pending records to ContextServe.ai and marks them `synced_to_cloud = 1` and `synced_at = `. +5. **CLI Formats**: `lineagelens report` renders the formatted terminal table; `lineagelens report --json` outputs valid JSON parseable by `jq`. From f90859d7436a12bb61767b9240421c5290e53562 Mon Sep 17 00:00:00 2001 From: Ram Viswanadha Date: Mon, 28 Sep 2026 17:34:18 -0700 Subject: [PATCH 4/6] hybrid token gateway --- .../hybrid-token-gateway-litellm-laya-spec.md | 487 ++++++++++++++++++ 1 file changed, 487 insertions(+) create mode 100644 docs/hybrid-token-gateway-litellm-laya-spec.md diff --git a/docs/hybrid-token-gateway-litellm-laya-spec.md b/docs/hybrid-token-gateway-litellm-laya-spec.md new file mode 100644 index 0000000..8301ddf --- /dev/null +++ b/docs/hybrid-token-gateway-litellm-laya-spec.md @@ -0,0 +1,487 @@ +# Hybrid AI Token Gateway Specification: LiteLLM + Laya (System 1 / System 2) + +> **Document:** `docs/hybrid-token-gateway-litellm-laya-spec.md` +> **Version:** 1.0 +> **Status:** Ready for Implementation +> **Reference:** `business_cases/business_case_and_arch_spec_litellm_laya.md` +> **Applies to:** `lineagelens` MCP server & CLI · `contextserve` Gateway & Backend · `laya` Decision Engine + +--- + +## 1. Executive Summary & Objective + +Modern autonomous software engineering agents (LineageLens, Cursor, Claude Code, Copilot Workspace) execute tens of thousands of LLM queries per developer each month. Telemetry analysis indicates that **40%–60% of agent calls are simple decision primitives**: +* AST relevance filtering (*"Does this function signature belong to the query target?"*) +* Search continuation checks (*"Do we have enough graph context or should we continue expanding callers?"*) +* Categorical classification (*"Is this error an authentication failure or a network timeout?"*) +* Gating & safety validation. + +Routing these high-frequency, low-complexity operations to frontier autoregressive LLMs (e.g., Claude 3.5 Sonnet, GPT-4o) via third-party hosted aggregators like **OpenRouter** introduces: +1. **Third-Party Markup / Billing Fees**: Paying unnecessary margins and transaction buffers on millions of daily tokens. +2. **High Latency Floor**: 400ms–1500ms round-trip latency for simple binary/typed decisions. +3. **Data Egress Risks**: Proprietary code context traversing external brokers outside private enterprise VPCs. +4. **No Custom Model Hosting**: Inability to host proprietary, private, or specialized models on OpenRouter. + +### System Solution +This specification establishes a **100% self-hosted, sovereign Hybrid Token Gateway** comprising: +* **Gateway Layer (LiteLLM Proxy)**: High-throughput, open-source proxy providing unified OpenAI-compatible routing, virtual key generation, token counting, multi-tenant budget caps, and zero gateway surcharge. +* **System 1 Engine (Laya — `NandhaKishorM/laya`)**: Non-autoregressive decision engine running on CPU or low-cost GPU (T4/L4), completing typed decisions, scoring, and binary gating in a single forward pass in **< 20ms** at **~$0.00000005 per token**. +* **System 2 Engine (Frontier LLMs / vLLM)**: Deep multi-step reasoning and code generation models invoked only when escalated by System 1 or explicitly requested by the agent. +* **ContextServe Ledger & Metering**: Double-entry token transaction ledger for auditability, enterprise chargeback, and SaaS customer billing. + +--- + +## 2. System Architecture & Topology + +```mermaid +flowchart TD + subgraph Clients["Agents & Clients"] + Agent[LineageLens MCP / CLI / IDE Agent] + WebUI[ContextServe Dashboard] + end + + subgraph GatewayPod["ContextServe Gateway Pod (:4000)"] + LLMProxy[LiteLLM Proxy Core] + AuthHook[Virtual Key & Quota Validator] + MeterHook[Token Metering & Ledger Hook] + Router[Latency & Model Router] + end + + subgraph System1["System 1: Fast Decision Engine (:8000)"] + LayaAdapter[FastAPI OpenAI Adapter] + LayaCore[Laya Single-Pass Engine
Non-Autoregressive Classifier] + end + + subgraph System2["System 2: Generative Reasoning"] + Frontier[Frontier APIs
Claude 3.5 Sonnet / GPT-4o] + SelfHostedVLLM[Optional vLLM Engine
DeepSeek / Qwen Open Weights] + end + + subgraph Persistence["Storage & Ledger"] + LedgerDB[(PostgreSQL / SQLite
Token Ledger & Virtual Keys)] + end + + Agent -->|"POST /v1/chat/completions
(Bearer sk-cs-xxx)"| LLMProxy + LLMProxy --> AuthHook + AuthHook -->|"Verify Key & Budget"| LedgerDB + AuthHook --> Router + + Router -->|"model: laya-decision
laya-relevance-scorer"| LayaAdapter + LayaAdapter --> LayaCore + LayaCore -->|"Inference <20ms"| LayaAdapter + LayaAdapter -->|"200 OK + Synthetic Tokens"| Router + + Router -->|"model: claude-3-5-sonnet
gpt-4o"| Frontier + Router -->|"model: deepseek-coder-vllm"| SelfHostedVLLM + + Router --> MeterHook + MeterHook -->|"Async Append Transaction"| LedgerDB + MeterHook -->|"Stream / Response Payload"| Agent +``` + +--- + +## 3. Component Design & Specifications + +### 3.1 Laya Decision Adapter (`services/laya-service`) + +Laya (`NandhaKishorM/laya`) operates as a **non-autoregressive classifier and scorer** over text. It does not generate text token-by-token. To allow LiteLLM and standard OpenAI SDKs to communicate with Laya seamlessly, a lightweight FastAPI service wraps Laya. + +#### Responsibilities: +1. Expose standard OpenAI-compatible `/v1/chat/completions` and `/v1/models`. +2. Expose high-performance native decision RPC `/v1/decisions`. +3. Compute exact synthetic token usage (`prompt_tokens = len(prompt) // 4`, `completion_tokens = len(output) // 4`) for LiteLLM's ledger. +4. Maintain p99 latency under **25ms**. + +#### Service Implementation (`services/laya-service/main.py`): +```python +""" +ContextServe Laya Decision Engine Adapter +Exposes an OpenAI-compatible interface for LiteLLM routing. +""" +import time +import uuid +from typing import Any, Dict, List, Optional +from fastapi import FastAPI, HTTPException, Request, status +from pydantic import BaseModel, Field +import laya + +app = FastAPI( + title="ContextServe Laya Decision Engine", + version="1.0.0", + docs_url="/docs" +) + +# Load Laya model into memory on startup +engine = None + +@app.on_event("startup") +def load_laya(): + global engine + # Supports torch-backend: 'auto', 'cpu', 'cuda' + engine = laya.load_engine(torch_backend="auto") + +class ChatMessage(BaseModel): + role: str + content: str + +class ChatCompletionRequest(BaseModel): + model: str + messages: List[ChatMessage] + temperature: Optional[float] = 0.0 + max_tokens: Optional[int] = 16 + stream: Optional[bool] = False + +@app.get("/healthz") +def healthcheck(): + return {"status": "ok", "engine": "laya", "ready": engine is not None} + +@app.get("/v1/models") +def list_models(): + return { + "object": "list", + "data": [ + {"id": "laya-decision", "object": "model", "owned_by": "contextserve"}, + {"id": "laya-relevance-scorer", "object": "model", "owned_by": "contextserve"} + ] + } + +@app.post("/v1/chat/completions") +async def chat_completions(req: ChatCompletionRequest): + if engine is None: + raise HTTPException(status_code=503, detail="Laya engine not initialized") + + start_time = time.perf_counter() + if not req.messages: + raise HTTPException(status_code=400, detail="messages array cannot be empty") + + last_user_message = req.messages[-1].content + + # Execute single forward-pass decision or scoring + if "scorer" in req.model: + score = engine.score(last_user_message) + result_text = str(round(float(score), 4)) + else: + decision = engine.predict_typed(last_user_message) + result_text = str(decision.value if hasattr(decision, "value") else decision) + + duration_ms = (time.perf_counter() - start_time) * 1000.0 + + # Synthetic token calculation based on standard 4-bytes/token convention + prompt_tokens = max(1, len(last_user_message.encode("utf-8")) // 4) + completion_tokens = max(1, len(result_text.encode("utf-8")) // 4) + total_tokens = prompt_tokens + completion_tokens + + return { + "id": f"chatcmpl-laya-{uuid.uuid4().hex[:12]}", + "object": "chat.completion", + "created": int(time.time()), + "model": req.model, + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": result_text + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": total_tokens + }, + "latency_ms": round(duration_ms, 2) + } +``` + +--- + +### 3.2 LiteLLM Gateway Configuration (`docker/litellm/config.yaml`) + +LiteLLM acts as the single point of entry for all agent traffic. + +```yaml +model_list: + # ======================================================== + # SYSTEM 1: LAYA ULTRA-FAST DECISION & SCORING ENGINES + # ======================================================== + - model_name: laya-decision + litellm_params: + model: openai/laya-decision + api_base: http://laya-service:8000/v1 + api_key: "internal-laya-secret" + input_cost_per_token: 0.00000005 # $0.05 per 1M input tokens + output_cost_per_token: 0.00000005 # $0.05 per 1M output tokens + + - model_name: laya-relevance-scorer + litellm_params: + model: openai/laya-relevance-scorer + api_base: http://laya-service:8000/v1 + api_key: "internal-laya-secret" + input_cost_per_token: 0.00000005 + output_cost_per_token: 0.00000005 + + # ======================================================== + # SYSTEM 2: FRONTIER REASONING (DIRECT PROVIDER PRICING) + # ======================================================== + - model_name: claude-3-5-sonnet + litellm_params: + model: anthropic/claude-3-5-sonnet-20241022 + api_key: os.environ/ANTHROPIC_API_KEY + input_cost_per_token: 0.000003 # $3.00 per 1M input tokens + output_cost_per_token: 0.000015 # $15.00 per 1M output tokens + + - model_name: gpt-4o + litellm_params: + model: openai/gpt-4o + api_key: os.environ/OPENAI_API_KEY + input_cost_per_token: 0.0000025 # $2.50 per 1M input tokens + output_cost_per_token: 0.0000100 # $10.00 per 1M output tokens + + # ======================================================== + # OPTIONAL: ON-PREMISE vLLM REASONING (OPEN WEIGHTS) + # ======================================================== + - model_name: deepseek-coder + litellm_params: + model: openai/deepseek-ai/DeepSeek-Coder-V2-Lite + api_base: http://vllm-service:8000/v1 + api_key: "vllm-local-key" + input_cost_per_token: 0.0000002 + output_cost_per_token: 0.0000002 + +litellm_settings: + drop_params: true + telemetry: false + request_timeout: 45 + +general_settings: + master_key: os.environ/LITELLM_MASTER_KEY + database_url: os.environ/DATABASE_URL + store_model_in_db: true + alerting: ["slack", "email"] + +router_settings: + routing_strategy: "latency-based-routing" + enable_pre_call_checks: true + num_retries: 2 + fallbacks: + - laya-decision: ["claude-3-5-sonnet"] +``` + +--- + +## 4. ContextServe Database Schema & Token Ledger + +The metering database enforces tenant isolation, budget caps, rate limiting, and real-time balance deductions. + +```sql +PRAGMA journal_mode = WAL; +PRAGMA foreign_keys = ON; +PRAGMA synchronous = NORMAL; + +-- ============================================================================ +-- 1. TENANTS & TEAMS +-- ============================================================================ +CREATE TABLE IF NOT EXISTS tenants ( + id TEXT PRIMARY KEY, -- 'org_contextserve_001' + name TEXT NOT NULL, -- 'Core Engineering Platform' + plan_tier TEXT NOT NULL DEFAULT 'usage', -- 'developer' | 'growth' | 'enterprise' + balance_usd DECIMAL(12, 6) NOT NULL DEFAULT 0.000000, -- Prepaid funds + currency TEXT NOT NULL DEFAULT 'USD', + is_active BOOLEAN NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +-- ============================================================================ +-- 2. VIRTUAL API KEYS +-- ============================================================================ +CREATE TABLE IF NOT EXISTS virtual_keys ( + key_hash TEXT PRIMARY KEY, -- SHA-256 of issued 'sk-cs-...' + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + key_name TEXT NOT NULL, -- e.g. 'LineageLens-MCP-Prod' + max_budget_usd DECIMAL(10, 4), -- Hard limit in USD (NULL = unlimited) + spend_usd DECIMAL(10, 4) NOT NULL DEFAULT 0.0000, + rate_limit_rpm INTEGER DEFAULT 600, + rate_limit_tpm INTEGER DEFAULT 200000, + allowed_models TEXT, -- JSON array: '["laya-decision", "gpt-4o"]' + expires_at TEXT, -- ISO-8601 UTC string + is_active BOOLEAN NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE INDEX IF NOT EXISTS idx_virtual_keys_tenant ON virtual_keys(tenant_id); + +-- ============================================================================ +-- 3. TOKEN INVOCATION LEDGER (Append-Only Time Series) +-- ============================================================================ +CREATE TABLE IF NOT EXISTS token_ledger ( + transaction_id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + tenant_id TEXT NOT NULL REFERENCES tenants(id), + key_hash TEXT NOT NULL REFERENCES virtual_keys(key_hash), + request_id TEXT NOT NULL UNIQUE, -- UUID generated by LiteLLM Proxy + model_name TEXT NOT NULL, -- 'laya-decision' | 'gpt-4o' + tier TEXT NOT NULL, -- 'system1' | 'system2' + prompt_tokens INTEGER NOT NULL, + completion_tokens INTEGER NOT NULL, + total_tokens INTEGER NOT NULL, + duration_ms INTEGER NOT NULL, + cost_raw_usd DECIMAL(10, 6) NOT NULL, -- Raw hardware / provider cost + billed_amount_usd DECIMAL(10, 6) NOT NULL, -- Billed amount to tenant (with margin) + remaining_balance DECIMAL(12, 6) NOT NULL, -- Remaining tenant balance after deduction + status TEXT NOT NULL DEFAULT 'ok', -- 'ok' | 'blocked_quota' | 'error' + metadata TEXT -- JSON metadata (repo, branch, agent tool) +); + +CREATE INDEX IF NOT EXISTS idx_ledger_tenant_time ON token_ledger(tenant_id, timestamp); +CREATE INDEX IF NOT EXISTS idx_ledger_key ON token_ledger(key_hash); +CREATE INDEX IF NOT EXISTS idx_ledger_model ON token_ledger(model_name); +``` + +--- + +## 5. End-to-End Execution Sequence + +The diagram below details the exact lifecycle of an agent call hitting the hybrid gateway: + +```mermaid +sequenceDiagram + autonumber + actor Agent as LineageLens Agent + participant GW as LiteLLM Gateway (:4000) + participant DB as SQLite/PostgreSQL Ledger + participant Laya as Laya Decision Service (:8000) + participant Claude as Anthropic API (Frontier) + + Agent->>GW: POST /v1/chat/completions (model: "laya-decision", Bearer sk-cs-xxx) + GW->>DB: Check key validity & remaining budget + alt Budget Exceeded + DB-->>GW: Balance = $0.00 + GW-->>Agent: 402 Payment Required: Monthly Budget Ceiling Reached + else Quota Verified + DB-->>GW: Key OK, Active Balance: $42.50 + GW->>Laya: POST /v1/chat/completions (Prompt: "Is node relevant?") + Note over Laya: Non-autoregressive forward pass
Latency: 14ms + Laya-->>GW: 200 OK: {"content": "true", "usage": {"prompt_tokens": 120, "completion_tokens": 1}} + GW->>DB: Append to `token_ledger` (Deduct $0.000006) + GW-->>Agent: 200 OK: Decision response received + end + + opt Complex Code Generation / Architectural Reasoning Required + Agent->>GW: POST /v1/chat/completions (model: "claude-3-5-sonnet") + GW->>DB: Check quota + GW->>Claude: Stream completion (Prompt: 4,000 tokens) + Claude-->>GW: Stream chunks (800 output tokens) + GW->>DB: Append to `token_ledger` (Deduct $0.024) + GW-->>Agent: Final synthesized code + end +``` + +--- + +## 6. Docker Compose Orchestration + +Add this configuration to ContextServe's `docker-compose.yml` to run the complete hybrid gateway stack: + +```yaml +version: '3.8' + +services: + # ------------------------------------------------------------- + # LAYA SYSTEM 1 DECISION ENGINE + # ------------------------------------------------------------- + laya-service: + build: + context: ./services/laya-service + dockerfile: Dockerfile + container_name: contextserve-laya + restart: unless-stopped + ports: + - "8000:8000" + environment: + - TORCH_BACKEND=auto + - WORKERS=4 + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/healthz"] + interval: 10s + timeout: 5s + retries: 3 + + # ------------------------------------------------------------- + # LITELLM GATEWAY & PROXY + # ------------------------------------------------------------- + litellm-proxy: + image: ghcr.io/berriai/litellm:main-latest + container_name: contextserve-gateway + restart: unless-stopped + ports: + - "4000:4000" + volumes: + - ./docker/litellm/config.yaml:/app/config.yaml:ro + - ./data/gateway:/data + environment: + - LITELLM_MASTER_KEY=${LITELLM_MASTER_KEY:-sk-cs-master-secret} + - DATABASE_URL=sqlite:////data/ledger.sqlite + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + - OPENAI_API_KEY=${OPENAI_API_KEY} + depends_on: + laya-service: + condition: service_healthy + command: ["--config", "/app/config.yaml", "--port", "4000", "--num_workers", "8"] +``` + +--- + +## 7. LineageLens MCP Server Integration + +In `src/lineagelens/mcp/server.py`, routing decision queries to the hybrid gateway is straightforward: + +```python +import os +import httpx + +GATEWAY_URL = os.environ.get("CONTEXTSERVE_GATEWAY_URL", "http://localhost:4000") +GATEWAY_KEY = os.environ.get("CONTEXTSERVE_GATEWAY_KEY", "sk-cs-default") + +async def fast_decision(prompt: str) -> str: + """ + Executes a sub-20ms System 1 decision via the local LiteLLM -> Laya gateway. + """ + async with httpx.AsyncClient(timeout=2.0) as client: + resp = await client.post( + f"{GATEWAY_URL}/v1/chat/completions", + headers={"Authorization": f"Bearer {GATEWAY_KEY}"}, + json={ + "model": "laya-decision", + "messages": [{"role": "user", "content": prompt}] + } + ) + resp.raise_for_status() + data = resp.json() + return data["choices"][0]["message"]["content"] +``` + +--- + +## 8. Failure Modes, Fallback Policies & Circuit Breakers + +| Failure Mode | Detection | Gateway Fallback Policy | +| :--- | :--- | :--- | +| **Laya Service Down / OOM** | HTTP 500 / Timeout (> 200ms) | Auto-failover to lightweight frontier model (`gpt-4o-mini` or `claude-3-haiku`). Alert operations. | +| **Tenant Balance Depleted** | Remaining balance <= $0.00 | Return `402 Payment Required` with custom payload detailing replenish link. Soft-budget warnings sent at 85%. | +| **Frontier API Rate Limit (429)**| Upstream provider 429 | LiteLLM exponential backoff retry (2 attempts); automatic failover to secondary provider. | +| **Network Partition / Offline** | LiteLLM unreachable | LineageLens falls back to pure local compiler deterministic heuristics (`metrics.sqlite`). | + +--- + +## 9. Implementation Roadmap + +| Milestone | Target Horizon | Deliverables | Verification Criteria | +| :---: | :---: | :--- | :--- | +| **Phase 1** | Week 1 | • Dockerfile for `laya-service`
• FastAPI wrapper with `/v1/chat/completions`
• Benchmark suite (`tests/benchmarks/test_laya_latency.py`) | p99 latency < 25ms on CPU; 100% test pass | +| **Phase 2** | Week 2 | • `docker/litellm/config.yaml` integration
• Gateway container launch in `docker-compose.yml`
• Virtual key generation script | `curl http://localhost:4000/v1/chat/completions` routes to Laya | +| **Phase 3** | Week 3 | • `token_ledger` database table & triggers
• Budget cap enforcement & rate-limit validation
• CLI command `lineagelens gateway status` | Hard quota cutoff verified via automated test | +| **Phase 4** | Week 4 | • LineageLens MCP tool integration for fast AST/symbol filtering
• Telemetry sync with ContextServe cloud | Agent turnaround latency drops > 40% on test repo | +| **Phase 5** | Week 5 | • ContextServe web dashboard updates (Docs, Token Savings, Ledger Table) | Real-time usage chart displays System 1 vs System 2 split | From 4258753fb2f1426c7292902c2aa0e15356a0f528 Mon Sep 17 00:00:00 2001 From: Ram Viswanadha Date: Mon, 28 Sep 2026 20:46:33 -0700 Subject: [PATCH 5/6] change license --- LICENSE | 216 +-------------- README.md | 725 +++++++++++++++++++++++++++++++------------------ pyproject.toml | 2 +- 3 files changed, 480 insertions(+), 463 deletions(-) diff --git a/LICENSE b/LICENSE index 261eeb9..784ced0 100644 --- a/LICENSE +++ b/LICENSE @@ -1,201 +1,15 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. + AFFERO GENERAL PUBLIC LICENSE + Version 2, November 2007 + + Copyright (C) 2007 Affero Inc. + 510 Third Street - Suite 225, San Francisco, CA 94107, USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +This is version 2 of the Affero General Public License. It gives each +licensee permission to distribute the Program or a work based on the Program +(as defined in version 1 of the Affero GPL) under the GNU Affero General +Public License, version 3 or any later version. If the Program was licensed +under version 1 of the Affero GPL "or any later version", no additional +obligations are imposed on any author or copyright holder of the Program as +a result of a licensee's choice to follow this version 2 of the Affero GPL. diff --git a/README.md b/README.md index 4c45820..663dbf8 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,122 @@ # LineageLens -A deterministic code graph for coding agents. Control flow, data flow and -cross-service contracts across six languages, in one SQLite file. +> **Real-Time Code Lineage & Visual Analytics** +> A deterministic code graph for coding agents. Control flow, data flow, and cross-service contracts across seven languages, in one SQLite file. -The problem it solves: an agent asked to fix a bug or plan a feature reads -whole files to reconstruct what calls what, where a value comes from, and what -a change will break. That is slow, expensive, and gets worse as the repository -grows. LineageLens answers those questions directly — with the source you need -and the honest statement of what it could not determine. +The problem it solves: an agent asked to fix a bug or plan a feature reads whole files to reconstruct what calls what, where a value comes from, and what a change will break. That is slow, expensive, and gets worse as the repository grows. LineageLens answers those questions directly — with the source you need and an honest statement of what it could not determine. + +> **100% Local & Offline**: LineageLens requires no account, no login, and no API key. All indexing, call graph generation, and MCP queries run entirely inside your local machine in SQLite (`.lineagelens/graph.db`). Zero telemetry by default. + +--- + +## ⚡ Quick Start + +### 1. Recommended Setup: Docker Compose Sidecar (Cursor & VS Code) + +Keep a warm LineageLens container running in the background for sub-millisecond MCP query latency without polluting your host toolchain: + +Create `docker-compose.mcp.yml` in your project root: + +```yaml +services: + lineagelens: + image: ghcr.io/contextserve/lineagelens:latest + container_name: lineagelens + restart: unless-stopped + working_dir: /workspace + entrypoint: sleep + command: infinity + volumes: + - .:/workspace + # environment: + # - LINEAGELENS_API_TOKEN=${LINEAGELENS_API_TOKEN} +``` ```bash -pip install lineagelens +# Start the persistent MCP sidecar +docker compose -f docker-compose.mcp.yml up -d + +# Index your project instantly inside the running sidecar +docker exec lineagelens lineagelens index . + +# Query the graph +docker exec lineagelens lineagelens query . impact src/orders/service.py:412 +``` + +--- + +### Alternative: Single Docker Run Sidecar + +```bash +# Start container in detached mode (persistent execution sidecar) +docker run --rm -d --name lineagelens \ + -v $(pwd):/workspace \ + -w /workspace \ + --entrypoint sleep \ + ghcr.io/contextserve/lineagelens:latest infinity +``` + +--- + +### 2. Shell Alias (Feels 100% Native) + +Add this alias to your shell profile (`~/.zshrc` or `~/.bashrc`) to run LineageLens with native CLI speed: + +```bash +alias lineagelens='docker exec lineagelens lineagelens' +``` + +```bash +# Reload profile +source ~/.zshrc + +# Now run commands directly: lineagelens index . lineagelens query . impact src/orders/service.py:412 +lineagelens coverage . +``` + +--- + +### 3. One-Off Execution (Ephemeral `docker run`) -# Or run with Docker (zero install, zero host toolchain pollution): +Run without keeping a container running in the background: + +```bash +# One-off index docker run --rm -v $(pwd):/workspace -w /workspace ghcr.io/contextserve/lineagelens:latest lineagelens index . + +# One-off query docker run --rm -v $(pwd):/workspace -w /workspace ghcr.io/contextserve/lineagelens:latest lineagelens query . impact src/orders/service.py:412 ``` -> **100% Local & Offline**: LineageLens requires no account, no login, and no API key. All indexing, call graph generation, and MCP queries run entirely inside your local machine in SQLite (`.lineagelens/graph.db`). Zero telemetry by default. +--- +### 4. Native Installation (Python 3.10+) -## What it answers +```bash +pip install lineagelens +lineagelens index . +lineagelens query . impact src/orders/service.py:412 +``` + +--- + +### Available Docker Images + +| Tag / Target | Runtimes Included | Best For | +|---|---|---| +| `:latest` / `:monolith` | All 7 runtimes + all SCIP indexers | Multi-language & polyglot codebases | +| `:node` | Python + Node.js LTS & scip-typescript | TypeScript and JavaScript projects | +| `:java` | Python + OpenJDK 17 & scip-java | Java, Kotlin, Scala / Maven & Gradle | +| `:go` | Python + Go runtime & scip-go | Go services & modules | +| `:rust` | Python + Rust & rust-analyzer | Rust crates & workspaces | +| `:clang` | Python + LLVM / Clang & scip-clang | C and C++ projects | +| `:ruby` | Python + Ruby & scip-ruby | Ruby and Rails projects | + +--- + +## 🔍 What It Answers | Question | Command | |---|---| @@ -35,16 +129,13 @@ docker run --rm -v $(pwd):/workspace -w /workspace ghcr.io/contextserve/lineagel | What does this stack trace touch? | `query . stacktrace error.log` | | What does the index *not* cover? | `coverage` | -Every answer carries a **coverage envelope**: which boundaries the walk -crossed, which files were skipped, whether data flow was computed or deferred. -A negative answer tells you whether it means "nothing calls this" or "we could -not see". +Every answer carries a **coverage envelope**: which boundaries the walk crossed, which files were skipped, and whether data flow was computed or deferred. A negative answer tells you whether it means "nothing calls this" or "we could not see". -## Cross-service flow +--- + +## 🌐 Cross-Service Flow -The capability no call graph has. A TypeScript `fetch` and a Python route -handler never reference each other — but both name the same route, so both -attach to one contract node: +The capability traditional call graphs lack. A TypeScript `fetch` and a Python route handler never reference each other directly — but both name the same route, so both attach to one contract node: ``` web/client.ts placeOrder ──CONSUMES──▶ POST /api/orders ◀──EXPOSES── create_order api/routes.py @@ -60,13 +151,9 @@ contract_map 1 of 1 cross_service: true ``` -Thirty framework adapters ship with it: FastAPI, Flask, Django, Spring, -Express, NestJS, ASP.NET, Go mux, fetch/axios/requests/RestTemplate, Dubbo -`@DubboService`/`@DubboReference`, gRPC, Kafka, Celery, `click`, env vars, and -Java SPI (`META-INF/services`). +Thirty framework adapters ship out of the box: FastAPI, Flask, Django, Spring, Express, NestJS, ASP.NET, Go mux, fetch/axios/requests/RestTemplate, Dubbo `@DubboService`/`@DubboReference`, gRPC, Kafka, Celery, `click`, env vars, and Java SPI (`META-INF/services`). -**A framework with no adapter is reported, not ignored.** Anything -route-shaped that no adapter claims comes back as a ranked work list: +**Unmodelled frameworks are reported, not ignored.** Anything route-shaped that no adapter claims comes back as a ranked work list: ``` frameworks present but unmodelled (no contract adapter): @@ -74,10 +161,11 @@ frameworks present but unmodelled (no contract adapter): add an adapter under .lineagelens/adapters/ to link these ``` -Adapters are YAML, so adding your in-house framework does not mean patching the -package. +Adapters are simple YAML files, so adding your in-house framework does not require patching the package. -## Line-level impact +--- + +## 🎯 Line-Level Impact ```console $ lineagelens query . impact api/routes.py:4 @@ -87,17 +175,18 @@ operations : EXPOSES -> POST /api/orders cross_service: placeOrder via POST /api/orders ``` -`change_kind` matters because the blast radii differ by orders of magnitude. A -**body** change is local. A **signature** change breaks every caller. A string -literal that happens to be a route key is a **contract** change with -cross-service reach — and impact separates in-process consequences (a build or -test failure, one deploy) from cross-service ones (wire compatibility and -deploy ordering). +`change_kind` matters because blast radii differ by orders of magnitude: +- A **body** change is local. +- A **signature** change breaks every caller. +- A string literal that happens to be a route key is a **contract** change with cross-service reach. + +LineageLens separates in-process consequences (a build or test failure, one deploy) from cross-service ones (wire compatibility and deploy ordering). -## Data flow +--- + +## 🌊 Data Flow -Follows `READS`, `WRITES`, `PARAM_BINDS` and `RETURNS`, so a walk crosses call -boundaries instead of stopping at them: +Follows `READS`, `WRITES`, `PARAM_BINDS`, and `RETURNS`, so a walk crosses call boundaries instead of stopping at them: ```console $ lineagelens query . dataflow submit/amount @@ -105,11 +194,11 @@ $ lineagelens query . dataflow submit/amount upstream READS amount -> submit ``` -Scope is deterministic: intraprocedural def-use, positional argument binding, -field read/write, return flow. Where a chain needs alias analysis or dynamic -dispatch it reports a **boundary with its candidate set** rather than guessing. +Scope is deterministic: intraprocedural def-use, positional argument binding, field read/write, and return flow. Where a chain needs alias analysis or dynamic dispatch, it reports a **boundary with its candidate set** rather than guessing. + +--- -## Intent: pay for the detail you need +## 💡 Intent: Pay for the Detail You Need A bug fix needs line-exact detail. A feature plan does not. @@ -118,79 +207,184 @@ lineagelens query . impact svc.py:412 --intent precise # spans, data flow, sou lineagelens query . impact OrderService --intent plan # shape only, ~10x cheaper ``` -`plan` computes no data flow and returns no source; the envelope says so. This -is the main cost control, and the saving comes from *not computing* detail the -question skips — never from truncating detail it needed. +`plan` computes no data flow and returns no source; the envelope says so. This is the main cost control: savings come from *not computing* detail the question skips — never from truncating detail it needed. -## Languages +--- -| | nodes | calls | inherits | implements | data flow | contracts | -|---|---|---|---|---|---|---| -| Python | ✓ | ✓ | ✓ | n/a | ✓ | ✓ | -| TypeScript / TSX | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| JavaScript | ✓ | ✓ | ✓ | n/a | ✓ | ✓ | -| Java | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| Go | ✓ | ✓ | ✓ | partial | ✓ | — | -| Rust | ✓ | ✓ | n/a | partial | ✓ | — | -| C# | ✓ | ✓ | ✓ | ✓ | ✓ | — | +## 🤖 MCP Integration & Real-Time IDE Setup + +LineageLens exposes 15 tools via the Model Context Protocol (MCP). Agents query graph structure instead of reading raw files — typically reducing context consumption by 10×. + +### Claude / Claude Code + +Configure `.claude/settings.json` or Claude Desktop: + +```json +{ + "mcpServers": { + "lineagelens": { + "command": "docker", + "args": [ + "run", + "--rm", + "-i", + "-v", + "${workspaceFolder}:/workspace", + "-w", + "/workspace", + "ghcr.io/contextserve/lineagelens:latest", + "lineagelens", + "mcp", + "." + ] + } + } +} +``` -Generated by the conformance suite, not written by hand. The two `partial` -entries are honest: Go satisfies interfaces structurally and Rust has blanket -impls, so neither has a single resolvable target. +### Real-Time IDE Setup (Cursor & VS Code) -Everything above works with no external toolchain — tree-sitter grammars are -pinned dependencies and install with the package. An optional **type resolver** -raises resolution quality further: Python has one vendored (jedi), and Java, -Go, Rust and C# can use a detected system toolchain. Without one a language -still indexes; more references are recorded as *ambiguous* rather than -resolved, and none are guessed. `lineagelens ontology` reports what your -machine has. +#### Step 1: Run Persistent Sidecar in Docker -A polyglot repository produces **one** graph. Language is a property of a file, -not of a repository. +```bash +docker run --rm -d --name lineagelens \ + -v $(pwd):/workspace \ + -w /workspace \ + --entrypoint sleep \ + ghcr.io/contextserve/lineagelens:latest infinity +``` -## Honesty by construction +#### Step 2: Configure IDE with `mcp.json` -Four properties, each enforced by a test rather than intended: +Add the server to `.cursor/mcp.json` (Cursor) or `.vscode/mcp.json` (VS Code): -**Never guesses.** When several candidates match a reference, no edge is -emitted and the ambiguity is recorded with its candidate set. An ambiguity is a -useful answer; a wrong edge is not. +**Option A: Recommended — Connect to Running Sidecar (`docker exec`)** + +```json +{ + "mcpServers": { + "lineagelens": { + "command": "docker", + "args": [ + "exec", + "-i", + "lineagelens", + "lineagelens", + "mcp", + "/workspace" + ] + } + } +} +``` -**Never silently degrades.** A missing *grammar* skips the file with a recorded -reason — there is no regex fallback. A missing *type resolver* lowers resolution -rather than blocking the language, and the tier used is reported per language on -every answer. Either way the degradation is in the output, not hidden. +> **Note for VS Code**: VS Code native MCP in `.vscode/mcp.json` uses the top-level key `"servers"` (e.g. `{"servers": {"lineagelens": {...}}}`), whereas Claude Desktop and Cursor use `"mcpServers"`. -**Never loses a reference.** Every observed reference becomes an edge or an -`unresolved_refs` row, and the per-file totals must balance — enforced by a -database constraint. +**Option B: On-Demand Docker Execution (`docker run`)** -**Reproducible.** `lineagelens verify` builds twice and compares digests. Same -commit, same graph. +```json +{ + "mcpServers": { + "lineagelens": { + "command": "docker", + "args": [ + "run", + "--rm", + "-i", + "-v", + "${workspaceFolder}:/workspace", + "-w", + "/workspace", + "ghcr.io/contextserve/lineagelens:latest", + "lineagelens", + "mcp", + "/workspace" + ] + } + } +} +``` -## MCP +**Option C: Native Host CLI** ```json { "mcpServers": { "lineagelens": { "command": "lineagelens-mcp", - "env": {"LINEAGELENS_PROJECT": "/path/to/repo"} + "env": { + "LINEAGELENS_PROJECT": "${workspaceFolder}" + } } } } ``` -Fifteen tools: `find_paths`, `trace_flow`, `callers_of`, `callees_of`, -`impact_of`, `impact_of_diff`, `dataflow_of`, `similar_flows`, `contract_map`, -`list_entry_points`, `search`, `get_symbol`, `explain`, `map_stacktrace`, -`coverage_report`, `get_ontology`. +#### Step 3: Run the Real-Time Watcher + +Keep the graph continuously synchronized as you edit code: + +```bash +# Run the watcher inside the running MCP container +docker exec -it lineagelens python -c "from lineagelens.watcher import IndexWatcher; IndexWatcher('.').run()" + +# Or run as a standalone detached watcher container +docker run --rm -d --name lineagelens-watcher \ + -v $(pwd):/workspace \ + -w /workspace \ + ghcr.io/contextserve/lineagelens:latest \ + python -c "from lineagelens.watcher import IndexWatcher; IndexWatcher('.').run()" +``` + +--- -Responses carry verbatim line-numbered source at `intent=precise`, so there is -usually no follow-up file read. Every one is budgeted and reports truncation. +### MCP Tools Reference -## Commands +| Tool | Description | +|---|---| +| `find_paths` | All execution paths between two symbols. | +| `trace_flow` | Trace a value through call boundaries. | +| `callers_of` | Transitive callers of a symbol (with depth cap). | +| `callees_of` | Transitive callees of a symbol. | +| `impact_of` | Blast radius of changing a symbol or line. ⭐ Most useful. | +| `impact_of_diff` | Blast radius from a diff/patch, not a single symbol. | +| `dataflow_of` | Upstream + downstream data flow for a value. | +| `similar_flows` | Find structurally similar code patterns. | +| `contract_map` | All cross-service contracts (HTTP, RPC, Kafka, etc.). | +| `list_entry_points` | All API routes, CLI commands, and event consumers. | +| `search` | Fuzzy symbol search across the codebase. | +| `get_symbol` | Full metadata for one symbol. | +| `explain` | Compiler evidence for an edge (why two components connect). | +| `map_stacktrace` | Resolve a crash log or stack trace to graph nodes. | +| `get_ontology` | Active capabilities and index levels for this install. | + +Responses carry verbatim line-numbered source at `intent=precise`, so there is usually no follow-up file read. Every response is budgeted and reports truncation explicitly. + +--- + +## 📊 Local Token Savings & MCP Metrics (ROI Reporter) + +Every MCP tool invocation computes the ground-truth counterfactual baseline (source tokens an LLM would have read without LineageLens) versus the optimized response payload, saving metrics locally to `.lineagelens/metrics.sqlite`: + +```bash +lineagelens report # Net tokens saved, context reduction %, and ROI +lineagelens report --by-tool # Tool-by-tool breakdown +lineagelens report --period 7d # Filter by time window (today, 7d, 30d, all) +lineagelens report --model o1 # Price against target LLMs (gpt-4o, claude-3-5-sonnet, deepseek-r1, o1, o3-mini) +lineagelens report --sync # Sync metrics to ContextServe.ai dashboard +lineagelens report --json # Machine-readable JSON output +``` + +### Deterministic Counterfactual Calculation + +1. **Baseline**: For each tool call, LineageLens calculates the exact tokens in the full files that an agent would have needed to read sequentially to answer the question without a graph. +2. **Actual**: Measures the compact JSON payload returned by LineageLens. +3. **Net Saved**: $\text{Baseline Tokens} - \text{Actual Tokens}$. +4. **ROI**: Value calculated using live input/output token pricing for selected LLMs. + +--- + +## 🛠️ CLI Commands ``` lineagelens index [path] build the graph (all languages, one graph) @@ -201,9 +395,23 @@ lineagelens index [path] build the graph (all languages, one graph) --upload publish the graph to ContextServe (opt-in) --upload-required make a publish failure fatal --dry-run print the exact payload, send nothing + lineagelens query [path] ... the twelve primitives; --json for machine output + impact blast radius analysis + paths execution paths + callers transitive callers + callees transitive callees + dataflow upstream/downstream value flow + contracts cross-service HTTP/RPC contract map + similar structurally similar flows + stacktrace map stack trace to graph + search fuzzy symbol search + get_symbol symbol metadata + explain compiler evidence for an edge + map_stacktrace resolve crash log + lineagelens coverage [path] what the index does and does not cover -lineagelens verify [path] rebuild twice, confirm identical +lineagelens verify [path] rebuild twice, confirm identical output lineagelens ontology [path] measured capability for this installation lineagelens mcp [path] run the MCP server over stdio lineagelens serve [path] HTTP surface for the dashboard (loopback default) @@ -212,245 +420,240 @@ lineagelens telemetry ... enable, disable, status -- off until you enable i lineagelens auth ... login, logout, status, token, switch-env ``` -## Local Token Savings & MCP Metrics - -Every MCP tool invocation computes the ground-truth counterfactual baseline (source tokens an LLM would have read without LineageLens) versus the optimized response payload, saving the metrics locally to `.lineagelens/metrics.sqlite`: - -```bash -lineagelens report # View net tokens saved, context reduction %, and ROI -lineagelens report --by-tool # Tool-by-tool breakdown -lineagelens report --period 7d # Filter by time window (today, 7d, 30d, all) -lineagelens report --model o1 # Price against target LLMs (gpt-4o, claude-3-5-sonnet, deepseek-r1, o1, o3-mini) -lineagelens report --sync # Sync metrics to ContextServe.ai dashboard -lineagelens report --json # Machine-readable JSON output -``` - -## Telemetry +--- -Off until you turn it on. `DO_NOT_TRACK` and `CI` suppress it permanently and -cannot be overridden by configuration. +## 🌐 Languages & Capability Levels -``` -lineagelens telemetry status prints the exact event, not a description -``` +Breadth and depth are independent axes. A language enters at a **level** derived from what extraction specs loaded, never declared: -Anonymous events are counts, versions and digests. Never a path, symbol, -repository name, branch or line of source — enforced by an assertion over the -event keys, not by review. Full field list and reasoning in -[docs/TELEMETRY.md](docs/TELEMETRY.md). - -## Languages +| Level | Delivers | Languages | +|---|---|---| +| **L2** flow | Data flow, contracts, call graph, symbol inventory | Python, Java, TypeScript, JavaScript, Go, Rust, C# | +| **L1** graph | Call graph, `impact_of`, `callers_of` | — | +| **L0** inventory | Symbols, `search`, `get_symbol` | C, Ruby, Bash, Kotlin | -Breadth and depth are independent axes, so a language enters at a **level** -rather than being claimed or not claimed: +### Feature Matrix -| Level | Delivers | Languages | -| --- | --- | --- | -| **L2** flow | data flow, contracts, everything below | python, java, typescript, javascript, go, rust, csharp | -| **L1** graph | call graph, `impact_of`, `callers_of` | — | -| **L0** inventory | symbols, `search`, `get_symbol` | c, ruby, bash, kotlin | +| Language | Nodes | Calls | Inherits | Implements | Data Flow | Contracts | +|---|---|---|---|---|---|---| +| **Python** | ✓ | ✓ | ✓ | n/a | ✓ | ✓ | +| **TypeScript / TSX** | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| **JavaScript** | ✓ | ✓ | ✓ | n/a | ✓ | ✓ | +| **Java** | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| **Go** | ✓ | ✓ | ✓ | partial | ✓ | — | +| **Rust** | ✓ | ✓ | n/a | partial | ✓ | — | +| **C#** | ✓ | ✓ | ✓ | ✓ | ✓ | — | -The level is *derived* from which extraction specs loaded, never declared, and -the conformance run fails if a claimed level is not corroborated by what the -corpus produced. `lineagelens ontology` prints it; every query response carries -it, so an agent knows a missing call answer on an L0 language is *unavailable* -rather than *absent*. +*Generated by the conformance suite. Go satisfies interfaces structurally and Rust has blanket impls, so both report `partial`.* -The core seven install by default. Breadth grammars are extras: +The core seven install by default. Breadth grammars are available via extras: -``` +```bash pip install 'lineagelens[wave1]' # c, ruby, bash, kotlin pip install 'lineagelens[all]' ``` -A file whose grammar is not installed is skipped with -`skip_reason = 'missing_grammar'` and reported in the coverage envelope — never -silently ignored. +A file whose grammar is not installed is skipped with `skip_reason = 'missing_grammar'` and reported in the coverage envelope — never silently ignored. Adding a new language is documented in [docs/ADDING-A-LANGUAGE.md](docs/ADDING-A-LANGUAGE.md). -Adding one is documented end to end in -[docs/ADDING-A-LANGUAGE.md](docs/ADDING-A-LANGUAGE.md). +--- -## Compiler-grade resolution +## 🔬 Compiler-Grade Resolution (SCIP) -Tier A is tree-sitter and always runs. Tier B is a type resolver, and a SCIP -index is the one that covers more than Python: +- **Tier A**: Tree-sitter AST queries (always active). +- **Tier B**: Type resolvers (Python uses vendored `jedi`; Java, Go, Rust, and C# use detected toolchains). +- **SCIP**: Compiler-verified facts from external SCIP indexers (`scip-java`, `scip-typescript`, `scip-python`, `scip-go`). Outranks static-analysis inferences. -``` +```bash +# Generate SCIP index and build graph scip-java index # or scip-typescript / scip-python / scip-go lineagelens index --scip # discovers ./index.scip -lineagelens index --scip=path.scip # or point at one +lineagelens index --scip=path.scip # or specify an explicit path + +# Strict mode for CI: skip or fail on languages without Tier B/SCIP +lineagelens index . --require-tier-b ``` -A SCIP answer is `evidence: fact` with label `scip_resolve`, and it outranks -jedi — a compiler-verified fact beats a static-analysis inference. +- **LineageLens never runs indexers for you**: Doing so would make indexing non-deterministic and break `verify --determinism`. +- **Stale index protection**: Each SCIP document's text is hashed against disk; any file that differs falls back to Tier A with the count reported. -**LineageLens never runs the indexer for you.** Subprocessing `scip-java` -inside `index` would make indexing network-dependent, slow and -non-deterministic, and `verify --determinism` could not hold. When a toolchain -is present and no index is, the CLI prints the command to generate one. +--- -**A stale index is refused per file.** Each SCIP document's text is hashed -against what is on disk; a file that differs gets no SCIP answers and falls -back to Tier A, with the count reported. An index built against a different -commit would otherwise produce confident edges that do not match the code — -the exact failure the rewrite was written to eliminate. +## 🔒 Honesty by Construction -## Publishing a graph +Four invariant properties, each enforced by tests: -Indexing is local and offline. `--upload` is the only thing that sends -anything, and a test fails on any outbound socket during a plain `index`. +1. **Never guesses**: When multiple candidates match a reference, no edge is emitted and the ambiguity is recorded with its candidate set. +2. **Never silently degrades**: A missing grammar or type resolver is explicitly reported in the coverage envelope. +3. **Never loses a reference**: Every observed reference becomes an edge or an `unresolved_refs` row, and per-file totals must balance by database constraint. +4. **Reproducible**: `lineagelens verify` builds twice and compares digests. Same commit = same graph. -``` -lineagelens auth login once, per environment -lineagelens index --upload build, then publish -LINEAGELENS_TOKEN=… lineagelens index --upload for CI -``` +--- -Two properties worth knowing before you point it at private code. +## ☁️ Cloud Sync & Team Publishing (Optional) -**You can read the payload before it leaves.** `--dry-run` prints the exact -bytes to stdout and sends nothing: +Indexing and querying are 100% offline. Publishing is strictly opt-in: -``` -lineagelens index --dry-run | jq '.graph_data | keys' -``` +```bash +# 1. Login or provide token via environment variable +lineagelens auth login +# Or: export LINEAGELENS_API_TOKEN="ll_sk_..." -**Source text does not go.** Nodes carry a signature and a docstring — that is -the interface, and it is structure. File contents do not: `usage_sites` holds -verbatim lines (`context_before` / `context_line` / `context_after`) and those -columns are excluded, with an assertion over the payload keys that raises -rather than silently strips. Uploading source into a context product is a -materially different proposition from uploading its structure, and the -difference is visible in `src/lineagelens/upload.py`. +# 2. Build and publish +lineagelens index --upload -**A failed upload is not a failed build.** Offline, expired token, plan limit, -server outage — all print a diagnostic and exit 0 if the index succeeded. The -graph is the product; publication is a side effect. Use `--upload-required` -when you want CI to disagree. +# 3. For CI/CD (fail build if upload fails) +lineagelens index --upload --upload-required +``` -## Scale +### Privacy & Zero Source Code Leakage -Apache Dubbo — 4,046 Java files, 120 Maven modules: +- **Inspect payload before sending**: `--dry-run` prints the exact bytes to stdout: + ```bash + lineagelens index --dry-run | jq '.graph_data | keys' + ``` +- **Source text is never uploaded**: Nodes carry only signatures and docstrings. Verbatim source lines in `usage_sites` are excluded via assertions in `src/lineagelens/upload.py` that raise rather than silently strip. +- **Failed uploads do not fail local builds**: Offline or expired tokens print a diagnostic and exit 0 unless `--upload-required` is set. -| | | -|---|---| -| nodes | 99,649 | -| edges | 411,445 | -| index time | 21s | -| dangling endpoints | 0 | -| contracts found | 378 | +--- -## Requirements +## 🐳 Docker Sandbox & Distribution -Python 3.10+, and nothing else. All eight tree-sitter grammars are pinned core -dependencies, so every language indexes out of the box. +The `Dockerfile` uses a multi-stage architecture providing isolated environments with clean APT caches: -Optional type resolvers improve resolution but are not required. Python's -(jedi) is vendored. Java, Go, Rust and C# use a detected system toolchain -(`javac`, `gopls`, `rust-analyzer`, `dotnet`); without one, those languages -index at Tier A and the coverage envelope says so on every query. Use -`--require-tier-b` in CI if a partial graph should be an error. +### Multi-Stage Build Targets -## Documentation +```bash +# Build language-specific slim image +docker build --target node -t ghcr.io/contextserve/lineagelens:node . +docker build --target java -t ghcr.io/contextserve/lineagelens:java . +docker build --target go -t ghcr.io/contextserve/lineagelens:go . -- [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) — architecture, adding a language, - writing an adapter, the invariants and where they are enforced -- [docs/ADDING-A-LANGUAGE.md](docs/ADDING-A-LANGUAGE.md) — the capability - ladder and the exact artifacts a new language needs -- [docs/TELEMETRY.md](docs/TELEMETRY.md) — every field sent, what is never - sent, and how to switch it off -- Design rationale and measured baselines: issue #51 +# Build all-in-one monolith image +docker build --target monolith -t ghcr.io/contextserve/lineagelens:latest . +``` +### Multi-Language Polyglot Pipelines + +```bash +# Pattern 1: Monolith (one container, all languages) +docker run --rm -v $(pwd):/workspace -w /workspace \ + ghcr.io/contextserve/lineagelens:latest \ + bash -c "scip-java index && npm install && scip-typescript index && lineagelens index ." + +# Pattern 2: Multi-stage pipeline (shared volume) +docker run --rm -v my-ws:/workspace -w /workspace \ + ghcr.io/contextserve/lineagelens:node \ + bash -c "npm install && scip-typescript index -o index-ts.scip" + +docker run --rm -v my-ws:/workspace -w /workspace \ + ghcr.io/contextserve/lineagelens:java \ + bash -c "scip-java index -o index-java.scip" + +docker run --rm -v my-ws:/workspace -w /workspace \ + ghcr.io/contextserve/lineagelens:latest \ + lineagelens index . +``` +### Enterprise Edge Cases +```bash +# Shadow node_modules for cross-OS native binary compatibility +docker run --rm \ + -v $(pwd):/workspace \ + -v /workspace/node_modules \ + -w /workspace \ + ghcr.io/contextserve/lineagelens:node \ + bash -c "npm install && scip-typescript index && lineagelens index ." + +# Mount private npm / SSH keys for registry access +docker run --rm \ + -v $(pwd):/workspace \ + -v ~/.npmrc:/root/.npmrc:ro \ + -v ~/.ssh:/root/.ssh:ro \ + -w /workspace \ + ghcr.io/contextserve/lineagelens:node \ + bash -c "npm install && scip-typescript index" +``` -## Dockerfile build +--- -Refactored the root Dockerfile to use the Multi-Stage Build Target architecture. -This allowed us to define a shared base layer and then branch off into 8 -isolated environments (node, java, go, ruby, rust, clang, and the monolith) -within a single, highly maintainable file. +## 🛡️ Telemetry -To build the Node variant, for example, your CI pipeline just runs: +Off by default. `DO_NOT_TRACK` and `CI` environment variables suppress it permanently. ```bash -docker build --target node -t registry.contextserve.ai/lineagelens/cli:node . +lineagelens telemetry status # Inspect the exact event +lineagelens telemetry enable # Opt in +lineagelens telemetry disable # Opt out ``` -To build the massive all-in-one image: +Anonymous events contain only counts, execution duration, and OS versions — never a path, symbol, repository name, branch, or line of code. Full specifications are documented in [docs/TELEMETRY.md](docs/TELEMETRY.md). -```bash -docker build --target monolith -t registry.contextserve.ai/lineagelens/cli:latest . -``` +--- -All stages have been optimized to clean their APT caches to keep the image sizes as small as possible! +## 📈 Scale -Build the index +Benchmark on Apache Dubbo (4,046 Java files, 120 Maven modules): -``` -docker run --rm -v $(pwd):/workspace ghcr.io/contextserve/lineagelens:latest lineagelens index -``` +| Metric | Result | +|---|---| +| **Nodes** | 99,649 | +| **Edges** | 411,445 | +| **Index Time** | 21s | +| **Dangling Endpoints** | 0 | +| **Contracts Found** | 378 | + +--- -## Publishing to GHCR +## ❓ FAQ & Troubleshooting -Images are hosted on the GitHub Container Registry at `ghcr.io//lineagelens`. +
+Graph not found / MCP server fails to start -### Automated via GitHub Actions (recommended) +Run `docker exec lineagelens lineagelens index .` (or `lineagelens index .`) first. The SQLite database is created at `.lineagelens/graph.db` in your project root. +
-The workflow [`.github/workflows/docker-publish.yml`](.github/workflows/docker-publish.yml) -builds all 7 targets in parallel and pushes to GHCR automatically. +
+Symbol not found -**Triggers** +Symbols are fully qualified (e.g. `myapp.module.Class.method`). Use `lineagelens query . search ` to find the exact symbol ID. +
-| Event | Behaviour | -|---|---| -| Push to `main` | Builds every target, pushes `ghcr.io/…/lineagelens:`. The `monolith` target also gets `:latest`. | -| Push of a `v*` tag | Same as above **plus** versioned tags, e.g. `:node-1.2.3` and `:node-1.2`. | -| Pull request | Build only — no push. Validates the Dockerfile without touching the registry. | +
+Impact analysis shows 0 affected symbols -**One-time repo setup** +The symbol might be dead code, or callers are in an unindexed language. Run `lineagelens query . callers ` and inspect `lineagelens coverage .` output. +
-Enable the workflow to publish packages with the built-in `GITHUB_TOKEN` (no -extra secret required): +
+How do I add a custom framework adapter? -> **Settings → Actions → General → Workflow permissions** -> → select **Read and write permissions** → Save. +Adapters are simple YAML files placed under `.lineagelens/adapters/`. See [docs/ADDING-A-LANGUAGE.md](docs/ADDING-A-LANGUAGE.md) for schema details. No Python code modifications required. +
-Then push to `main` (or create a `v*` tag) and the workflow runs automatically. +
+Does LineageLens send my source code anywhere? -**Pulling a published image** +No. Source text is never included in uploads. Run `lineagelens index --dry-run` to inspect the exact payload before anything leaves your machine. +
-```bash -docker pull ghcr.io/ContextServe/lineagelens:node -docker pull ghcr.io/ContextServe/lineagelens:latest # monolith -``` +
+How do I verify the graph is reproducible? + +Run `lineagelens verify .`. It rebuilds the graph twice and asserts identical digests. Same commit = same graph. +
--- -### Manual push (local machine) +## 📚 Documentation -```bash -# 1. Authenticate (once per machine) -echo $CR_PAT | docker login ghcr.io -u --password-stdin -# CR_PAT = GitHub PAT with write:packages scope - -# 2. Build + tag in one step -TARGET=node # or java | go | rust | clang | ruby | monolith -docker build --target $TARGET \ - -t ghcr.io//lineagelens:$TARGET \ - . - -# 3. Push -docker push ghcr.io//lineagelens:$TARGET -``` - -Or tag an image that was already built locally: +- [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) — Architecture, writing adapters, and core invariants. +- [docs/ADDING-A-LANGUAGE.md](docs/ADDING-A-LANGUAGE.md) — Capability ladder and steps to add a language. +- [docs/TELEMETRY.md](docs/TELEMETRY.md) — Telemetry payload schema and privacy controls. +- [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md) — Contribution workflow and guidelines. -```bash -docker tag registry.contextserve.ai/lineagelens/cli:node ghcr.io//lineagelens:node -docker push ghcr.io//lineagelens:node -``` +--- -## Licence +## 📄 Licence -Apache-2.0 +[AGPL-2.0](LICENSE) (Affero General Public License Version 2) diff --git a/pyproject.toml b/pyproject.toml index e296d7a..d63e5d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ version = "3.0.6" description = "Deterministic multi-language code graph: control flow, data flow and cross-service contracts, for humans and coding agents." readme = "README.md" requires-python = ">=3.10" -license = { text = "Apache-2.0" } +license = { text = "AGPL-2.0" } authors = [{ name = "LineageLens contributors" }] classifiers = [ "Programming Language :: Python :: 3", From 8748a4e8ce1134b84353fee747b6c8b6a427e607 Mon Sep 17 00:00:00 2001 From: Ram Viswanadha Date: Mon, 28 Sep 2026 21:21:42 -0700 Subject: [PATCH 6/6] remove unwanted files --- .../hybrid-token-gateway-litellm-laya-spec.md | 487 ------------------ 1 file changed, 487 deletions(-) delete mode 100644 docs/hybrid-token-gateway-litellm-laya-spec.md diff --git a/docs/hybrid-token-gateway-litellm-laya-spec.md b/docs/hybrid-token-gateway-litellm-laya-spec.md deleted file mode 100644 index 8301ddf..0000000 --- a/docs/hybrid-token-gateway-litellm-laya-spec.md +++ /dev/null @@ -1,487 +0,0 @@ -# Hybrid AI Token Gateway Specification: LiteLLM + Laya (System 1 / System 2) - -> **Document:** `docs/hybrid-token-gateway-litellm-laya-spec.md` -> **Version:** 1.0 -> **Status:** Ready for Implementation -> **Reference:** `business_cases/business_case_and_arch_spec_litellm_laya.md` -> **Applies to:** `lineagelens` MCP server & CLI · `contextserve` Gateway & Backend · `laya` Decision Engine - ---- - -## 1. Executive Summary & Objective - -Modern autonomous software engineering agents (LineageLens, Cursor, Claude Code, Copilot Workspace) execute tens of thousands of LLM queries per developer each month. Telemetry analysis indicates that **40%–60% of agent calls are simple decision primitives**: -* AST relevance filtering (*"Does this function signature belong to the query target?"*) -* Search continuation checks (*"Do we have enough graph context or should we continue expanding callers?"*) -* Categorical classification (*"Is this error an authentication failure or a network timeout?"*) -* Gating & safety validation. - -Routing these high-frequency, low-complexity operations to frontier autoregressive LLMs (e.g., Claude 3.5 Sonnet, GPT-4o) via third-party hosted aggregators like **OpenRouter** introduces: -1. **Third-Party Markup / Billing Fees**: Paying unnecessary margins and transaction buffers on millions of daily tokens. -2. **High Latency Floor**: 400ms–1500ms round-trip latency for simple binary/typed decisions. -3. **Data Egress Risks**: Proprietary code context traversing external brokers outside private enterprise VPCs. -4. **No Custom Model Hosting**: Inability to host proprietary, private, or specialized models on OpenRouter. - -### System Solution -This specification establishes a **100% self-hosted, sovereign Hybrid Token Gateway** comprising: -* **Gateway Layer (LiteLLM Proxy)**: High-throughput, open-source proxy providing unified OpenAI-compatible routing, virtual key generation, token counting, multi-tenant budget caps, and zero gateway surcharge. -* **System 1 Engine (Laya — `NandhaKishorM/laya`)**: Non-autoregressive decision engine running on CPU or low-cost GPU (T4/L4), completing typed decisions, scoring, and binary gating in a single forward pass in **< 20ms** at **~$0.00000005 per token**. -* **System 2 Engine (Frontier LLMs / vLLM)**: Deep multi-step reasoning and code generation models invoked only when escalated by System 1 or explicitly requested by the agent. -* **ContextServe Ledger & Metering**: Double-entry token transaction ledger for auditability, enterprise chargeback, and SaaS customer billing. - ---- - -## 2. System Architecture & Topology - -```mermaid -flowchart TD - subgraph Clients["Agents & Clients"] - Agent[LineageLens MCP / CLI / IDE Agent] - WebUI[ContextServe Dashboard] - end - - subgraph GatewayPod["ContextServe Gateway Pod (:4000)"] - LLMProxy[LiteLLM Proxy Core] - AuthHook[Virtual Key & Quota Validator] - MeterHook[Token Metering & Ledger Hook] - Router[Latency & Model Router] - end - - subgraph System1["System 1: Fast Decision Engine (:8000)"] - LayaAdapter[FastAPI OpenAI Adapter] - LayaCore[Laya Single-Pass Engine
Non-Autoregressive Classifier] - end - - subgraph System2["System 2: Generative Reasoning"] - Frontier[Frontier APIs
Claude 3.5 Sonnet / GPT-4o] - SelfHostedVLLM[Optional vLLM Engine
DeepSeek / Qwen Open Weights] - end - - subgraph Persistence["Storage & Ledger"] - LedgerDB[(PostgreSQL / SQLite
Token Ledger & Virtual Keys)] - end - - Agent -->|"POST /v1/chat/completions
(Bearer sk-cs-xxx)"| LLMProxy - LLMProxy --> AuthHook - AuthHook -->|"Verify Key & Budget"| LedgerDB - AuthHook --> Router - - Router -->|"model: laya-decision
laya-relevance-scorer"| LayaAdapter - LayaAdapter --> LayaCore - LayaCore -->|"Inference <20ms"| LayaAdapter - LayaAdapter -->|"200 OK + Synthetic Tokens"| Router - - Router -->|"model: claude-3-5-sonnet
gpt-4o"| Frontier - Router -->|"model: deepseek-coder-vllm"| SelfHostedVLLM - - Router --> MeterHook - MeterHook -->|"Async Append Transaction"| LedgerDB - MeterHook -->|"Stream / Response Payload"| Agent -``` - ---- - -## 3. Component Design & Specifications - -### 3.1 Laya Decision Adapter (`services/laya-service`) - -Laya (`NandhaKishorM/laya`) operates as a **non-autoregressive classifier and scorer** over text. It does not generate text token-by-token. To allow LiteLLM and standard OpenAI SDKs to communicate with Laya seamlessly, a lightweight FastAPI service wraps Laya. - -#### Responsibilities: -1. Expose standard OpenAI-compatible `/v1/chat/completions` and `/v1/models`. -2. Expose high-performance native decision RPC `/v1/decisions`. -3. Compute exact synthetic token usage (`prompt_tokens = len(prompt) // 4`, `completion_tokens = len(output) // 4`) for LiteLLM's ledger. -4. Maintain p99 latency under **25ms**. - -#### Service Implementation (`services/laya-service/main.py`): -```python -""" -ContextServe Laya Decision Engine Adapter -Exposes an OpenAI-compatible interface for LiteLLM routing. -""" -import time -import uuid -from typing import Any, Dict, List, Optional -from fastapi import FastAPI, HTTPException, Request, status -from pydantic import BaseModel, Field -import laya - -app = FastAPI( - title="ContextServe Laya Decision Engine", - version="1.0.0", - docs_url="/docs" -) - -# Load Laya model into memory on startup -engine = None - -@app.on_event("startup") -def load_laya(): - global engine - # Supports torch-backend: 'auto', 'cpu', 'cuda' - engine = laya.load_engine(torch_backend="auto") - -class ChatMessage(BaseModel): - role: str - content: str - -class ChatCompletionRequest(BaseModel): - model: str - messages: List[ChatMessage] - temperature: Optional[float] = 0.0 - max_tokens: Optional[int] = 16 - stream: Optional[bool] = False - -@app.get("/healthz") -def healthcheck(): - return {"status": "ok", "engine": "laya", "ready": engine is not None} - -@app.get("/v1/models") -def list_models(): - return { - "object": "list", - "data": [ - {"id": "laya-decision", "object": "model", "owned_by": "contextserve"}, - {"id": "laya-relevance-scorer", "object": "model", "owned_by": "contextserve"} - ] - } - -@app.post("/v1/chat/completions") -async def chat_completions(req: ChatCompletionRequest): - if engine is None: - raise HTTPException(status_code=503, detail="Laya engine not initialized") - - start_time = time.perf_counter() - if not req.messages: - raise HTTPException(status_code=400, detail="messages array cannot be empty") - - last_user_message = req.messages[-1].content - - # Execute single forward-pass decision or scoring - if "scorer" in req.model: - score = engine.score(last_user_message) - result_text = str(round(float(score), 4)) - else: - decision = engine.predict_typed(last_user_message) - result_text = str(decision.value if hasattr(decision, "value") else decision) - - duration_ms = (time.perf_counter() - start_time) * 1000.0 - - # Synthetic token calculation based on standard 4-bytes/token convention - prompt_tokens = max(1, len(last_user_message.encode("utf-8")) // 4) - completion_tokens = max(1, len(result_text.encode("utf-8")) // 4) - total_tokens = prompt_tokens + completion_tokens - - return { - "id": f"chatcmpl-laya-{uuid.uuid4().hex[:12]}", - "object": "chat.completion", - "created": int(time.time()), - "model": req.model, - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": result_text - }, - "finish_reason": "stop" - } - ], - "usage": { - "prompt_tokens": prompt_tokens, - "completion_tokens": completion_tokens, - "total_tokens": total_tokens - }, - "latency_ms": round(duration_ms, 2) - } -``` - ---- - -### 3.2 LiteLLM Gateway Configuration (`docker/litellm/config.yaml`) - -LiteLLM acts as the single point of entry for all agent traffic. - -```yaml -model_list: - # ======================================================== - # SYSTEM 1: LAYA ULTRA-FAST DECISION & SCORING ENGINES - # ======================================================== - - model_name: laya-decision - litellm_params: - model: openai/laya-decision - api_base: http://laya-service:8000/v1 - api_key: "internal-laya-secret" - input_cost_per_token: 0.00000005 # $0.05 per 1M input tokens - output_cost_per_token: 0.00000005 # $0.05 per 1M output tokens - - - model_name: laya-relevance-scorer - litellm_params: - model: openai/laya-relevance-scorer - api_base: http://laya-service:8000/v1 - api_key: "internal-laya-secret" - input_cost_per_token: 0.00000005 - output_cost_per_token: 0.00000005 - - # ======================================================== - # SYSTEM 2: FRONTIER REASONING (DIRECT PROVIDER PRICING) - # ======================================================== - - model_name: claude-3-5-sonnet - litellm_params: - model: anthropic/claude-3-5-sonnet-20241022 - api_key: os.environ/ANTHROPIC_API_KEY - input_cost_per_token: 0.000003 # $3.00 per 1M input tokens - output_cost_per_token: 0.000015 # $15.00 per 1M output tokens - - - model_name: gpt-4o - litellm_params: - model: openai/gpt-4o - api_key: os.environ/OPENAI_API_KEY - input_cost_per_token: 0.0000025 # $2.50 per 1M input tokens - output_cost_per_token: 0.0000100 # $10.00 per 1M output tokens - - # ======================================================== - # OPTIONAL: ON-PREMISE vLLM REASONING (OPEN WEIGHTS) - # ======================================================== - - model_name: deepseek-coder - litellm_params: - model: openai/deepseek-ai/DeepSeek-Coder-V2-Lite - api_base: http://vllm-service:8000/v1 - api_key: "vllm-local-key" - input_cost_per_token: 0.0000002 - output_cost_per_token: 0.0000002 - -litellm_settings: - drop_params: true - telemetry: false - request_timeout: 45 - -general_settings: - master_key: os.environ/LITELLM_MASTER_KEY - database_url: os.environ/DATABASE_URL - store_model_in_db: true - alerting: ["slack", "email"] - -router_settings: - routing_strategy: "latency-based-routing" - enable_pre_call_checks: true - num_retries: 2 - fallbacks: - - laya-decision: ["claude-3-5-sonnet"] -``` - ---- - -## 4. ContextServe Database Schema & Token Ledger - -The metering database enforces tenant isolation, budget caps, rate limiting, and real-time balance deductions. - -```sql -PRAGMA journal_mode = WAL; -PRAGMA foreign_keys = ON; -PRAGMA synchronous = NORMAL; - --- ============================================================================ --- 1. TENANTS & TEAMS --- ============================================================================ -CREATE TABLE IF NOT EXISTS tenants ( - id TEXT PRIMARY KEY, -- 'org_contextserve_001' - name TEXT NOT NULL, -- 'Core Engineering Platform' - plan_tier TEXT NOT NULL DEFAULT 'usage', -- 'developer' | 'growth' | 'enterprise' - balance_usd DECIMAL(12, 6) NOT NULL DEFAULT 0.000000, -- Prepaid funds - currency TEXT NOT NULL DEFAULT 'USD', - is_active BOOLEAN NOT NULL DEFAULT 1, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) -); - --- ============================================================================ --- 2. VIRTUAL API KEYS --- ============================================================================ -CREATE TABLE IF NOT EXISTS virtual_keys ( - key_hash TEXT PRIMARY KEY, -- SHA-256 of issued 'sk-cs-...' - tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, - key_name TEXT NOT NULL, -- e.g. 'LineageLens-MCP-Prod' - max_budget_usd DECIMAL(10, 4), -- Hard limit in USD (NULL = unlimited) - spend_usd DECIMAL(10, 4) NOT NULL DEFAULT 0.0000, - rate_limit_rpm INTEGER DEFAULT 600, - rate_limit_tpm INTEGER DEFAULT 200000, - allowed_models TEXT, -- JSON array: '["laya-decision", "gpt-4o"]' - expires_at TEXT, -- ISO-8601 UTC string - is_active BOOLEAN NOT NULL DEFAULT 1, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) -); - -CREATE INDEX IF NOT EXISTS idx_virtual_keys_tenant ON virtual_keys(tenant_id); - --- ============================================================================ --- 3. TOKEN INVOCATION LEDGER (Append-Only Time Series) --- ============================================================================ -CREATE TABLE IF NOT EXISTS token_ledger ( - transaction_id INTEGER PRIMARY KEY AUTOINCREMENT, - timestamp TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - tenant_id TEXT NOT NULL REFERENCES tenants(id), - key_hash TEXT NOT NULL REFERENCES virtual_keys(key_hash), - request_id TEXT NOT NULL UNIQUE, -- UUID generated by LiteLLM Proxy - model_name TEXT NOT NULL, -- 'laya-decision' | 'gpt-4o' - tier TEXT NOT NULL, -- 'system1' | 'system2' - prompt_tokens INTEGER NOT NULL, - completion_tokens INTEGER NOT NULL, - total_tokens INTEGER NOT NULL, - duration_ms INTEGER NOT NULL, - cost_raw_usd DECIMAL(10, 6) NOT NULL, -- Raw hardware / provider cost - billed_amount_usd DECIMAL(10, 6) NOT NULL, -- Billed amount to tenant (with margin) - remaining_balance DECIMAL(12, 6) NOT NULL, -- Remaining tenant balance after deduction - status TEXT NOT NULL DEFAULT 'ok', -- 'ok' | 'blocked_quota' | 'error' - metadata TEXT -- JSON metadata (repo, branch, agent tool) -); - -CREATE INDEX IF NOT EXISTS idx_ledger_tenant_time ON token_ledger(tenant_id, timestamp); -CREATE INDEX IF NOT EXISTS idx_ledger_key ON token_ledger(key_hash); -CREATE INDEX IF NOT EXISTS idx_ledger_model ON token_ledger(model_name); -``` - ---- - -## 5. End-to-End Execution Sequence - -The diagram below details the exact lifecycle of an agent call hitting the hybrid gateway: - -```mermaid -sequenceDiagram - autonumber - actor Agent as LineageLens Agent - participant GW as LiteLLM Gateway (:4000) - participant DB as SQLite/PostgreSQL Ledger - participant Laya as Laya Decision Service (:8000) - participant Claude as Anthropic API (Frontier) - - Agent->>GW: POST /v1/chat/completions (model: "laya-decision", Bearer sk-cs-xxx) - GW->>DB: Check key validity & remaining budget - alt Budget Exceeded - DB-->>GW: Balance = $0.00 - GW-->>Agent: 402 Payment Required: Monthly Budget Ceiling Reached - else Quota Verified - DB-->>GW: Key OK, Active Balance: $42.50 - GW->>Laya: POST /v1/chat/completions (Prompt: "Is node relevant?") - Note over Laya: Non-autoregressive forward pass
Latency: 14ms - Laya-->>GW: 200 OK: {"content": "true", "usage": {"prompt_tokens": 120, "completion_tokens": 1}} - GW->>DB: Append to `token_ledger` (Deduct $0.000006) - GW-->>Agent: 200 OK: Decision response received - end - - opt Complex Code Generation / Architectural Reasoning Required - Agent->>GW: POST /v1/chat/completions (model: "claude-3-5-sonnet") - GW->>DB: Check quota - GW->>Claude: Stream completion (Prompt: 4,000 tokens) - Claude-->>GW: Stream chunks (800 output tokens) - GW->>DB: Append to `token_ledger` (Deduct $0.024) - GW-->>Agent: Final synthesized code - end -``` - ---- - -## 6. Docker Compose Orchestration - -Add this configuration to ContextServe's `docker-compose.yml` to run the complete hybrid gateway stack: - -```yaml -version: '3.8' - -services: - # ------------------------------------------------------------- - # LAYA SYSTEM 1 DECISION ENGINE - # ------------------------------------------------------------- - laya-service: - build: - context: ./services/laya-service - dockerfile: Dockerfile - container_name: contextserve-laya - restart: unless-stopped - ports: - - "8000:8000" - environment: - - TORCH_BACKEND=auto - - WORKERS=4 - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8000/healthz"] - interval: 10s - timeout: 5s - retries: 3 - - # ------------------------------------------------------------- - # LITELLM GATEWAY & PROXY - # ------------------------------------------------------------- - litellm-proxy: - image: ghcr.io/berriai/litellm:main-latest - container_name: contextserve-gateway - restart: unless-stopped - ports: - - "4000:4000" - volumes: - - ./docker/litellm/config.yaml:/app/config.yaml:ro - - ./data/gateway:/data - environment: - - LITELLM_MASTER_KEY=${LITELLM_MASTER_KEY:-sk-cs-master-secret} - - DATABASE_URL=sqlite:////data/ledger.sqlite - - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - - OPENAI_API_KEY=${OPENAI_API_KEY} - depends_on: - laya-service: - condition: service_healthy - command: ["--config", "/app/config.yaml", "--port", "4000", "--num_workers", "8"] -``` - ---- - -## 7. LineageLens MCP Server Integration - -In `src/lineagelens/mcp/server.py`, routing decision queries to the hybrid gateway is straightforward: - -```python -import os -import httpx - -GATEWAY_URL = os.environ.get("CONTEXTSERVE_GATEWAY_URL", "http://localhost:4000") -GATEWAY_KEY = os.environ.get("CONTEXTSERVE_GATEWAY_KEY", "sk-cs-default") - -async def fast_decision(prompt: str) -> str: - """ - Executes a sub-20ms System 1 decision via the local LiteLLM -> Laya gateway. - """ - async with httpx.AsyncClient(timeout=2.0) as client: - resp = await client.post( - f"{GATEWAY_URL}/v1/chat/completions", - headers={"Authorization": f"Bearer {GATEWAY_KEY}"}, - json={ - "model": "laya-decision", - "messages": [{"role": "user", "content": prompt}] - } - ) - resp.raise_for_status() - data = resp.json() - return data["choices"][0]["message"]["content"] -``` - ---- - -## 8. Failure Modes, Fallback Policies & Circuit Breakers - -| Failure Mode | Detection | Gateway Fallback Policy | -| :--- | :--- | :--- | -| **Laya Service Down / OOM** | HTTP 500 / Timeout (> 200ms) | Auto-failover to lightweight frontier model (`gpt-4o-mini` or `claude-3-haiku`). Alert operations. | -| **Tenant Balance Depleted** | Remaining balance <= $0.00 | Return `402 Payment Required` with custom payload detailing replenish link. Soft-budget warnings sent at 85%. | -| **Frontier API Rate Limit (429)**| Upstream provider 429 | LiteLLM exponential backoff retry (2 attempts); automatic failover to secondary provider. | -| **Network Partition / Offline** | LiteLLM unreachable | LineageLens falls back to pure local compiler deterministic heuristics (`metrics.sqlite`). | - ---- - -## 9. Implementation Roadmap - -| Milestone | Target Horizon | Deliverables | Verification Criteria | -| :---: | :---: | :--- | :--- | -| **Phase 1** | Week 1 | • Dockerfile for `laya-service`
• FastAPI wrapper with `/v1/chat/completions`
• Benchmark suite (`tests/benchmarks/test_laya_latency.py`) | p99 latency < 25ms on CPU; 100% test pass | -| **Phase 2** | Week 2 | • `docker/litellm/config.yaml` integration
• Gateway container launch in `docker-compose.yml`
• Virtual key generation script | `curl http://localhost:4000/v1/chat/completions` routes to Laya | -| **Phase 3** | Week 3 | • `token_ledger` database table & triggers
• Budget cap enforcement & rate-limit validation
• CLI command `lineagelens gateway status` | Hard quota cutoff verified via automated test | -| **Phase 4** | Week 4 | • LineageLens MCP tool integration for fast AST/symbol filtering
• Telemetry sync with ContextServe cloud | Agent turnaround latency drops > 40% on test repo | -| **Phase 5** | Week 5 | • ContextServe web dashboard updates (Docs, Token Savings, Ledger Table) | Real-time usage chart displays System 1 vs System 2 split |