provekit 0.2.0__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. {provekit-0.2.0 → provekit-0.6.0}/PKG-INFO +24 -1
  2. provekit-0.6.0/provekit/__init__.py +18 -0
  3. provekit-0.6.0/provekit/auto.py +11 -0
  4. provekit-0.6.0/provekit/config.py +68 -0
  5. provekit-0.6.0/provekit/demo.py +118 -0
  6. provekit-0.6.0/provekit/eval.py +74 -0
  7. {provekit-0.2.0 → provekit-0.6.0}/provekit/main.py +9 -1
  8. provekit-0.6.0/provekit/mcp.py +83 -0
  9. provekit-0.6.0/provekit/migrations/versions/a1b2c3d4e5f6_feedback_sessions.py +49 -0
  10. provekit-0.6.0/provekit/migrations/versions/b2c3d4e5f6a7_datasets.py +54 -0
  11. provekit-0.6.0/provekit/migrations/versions/c3d4e5f6a7b8_experiments.py +59 -0
  12. provekit-0.6.0/provekit/migrations/versions/d4e5f6a7b8c9_alerts.py +40 -0
  13. provekit-0.6.0/provekit/migrations/versions/e5f6a7b8c9d0_admin.py +30 -0
  14. provekit-0.6.0/provekit/models.py +188 -0
  15. {provekit-0.2.0 → provekit-0.6.0}/provekit/observability.py +2 -2
  16. provekit-0.6.0/provekit/routers/admin.py +82 -0
  17. provekit-0.6.0/provekit/routers/alerts.py +124 -0
  18. {provekit-0.2.0 → provekit-0.6.0}/provekit/routers/auth.py +3 -1
  19. provekit-0.6.0/provekit/routers/datasets.py +151 -0
  20. provekit-0.6.0/provekit/routers/experiments.py +141 -0
  21. provekit-0.6.0/provekit/routers/metrics.py +143 -0
  22. provekit-0.6.0/provekit/routers/projects.py +140 -0
  23. provekit-0.6.0/provekit/routers/traces.py +275 -0
  24. provekit-0.6.0/provekit/scorers.py +63 -0
  25. provekit-0.6.0/provekit/services/limits.py +73 -0
  26. {provekit-0.2.0 → provekit-0.6.0}/provekit/services/otel.py +25 -0
  27. provekit-0.6.0/provekit/services/redact.py +48 -0
  28. provekit-0.6.0/provekit/services/sealing.py +49 -0
  29. provekit-0.6.0/provekit/services/share.py +54 -0
  30. provekit-0.6.0/provekit/services/workspace.py +61 -0
  31. {provekit-0.2.0 → provekit-0.6.0}/provekit/trace.py +152 -12
  32. {provekit-0.2.0 → provekit-0.6.0}/provekit.egg-info/PKG-INFO +24 -1
  33. {provekit-0.2.0 → provekit-0.6.0}/provekit.egg-info/SOURCES.txt +34 -3
  34. provekit-0.6.0/provekit.egg-info/entry_points.txt +3 -0
  35. provekit-0.6.0/provekit.egg-info/requires.txt +52 -0
  36. {provekit-0.2.0 → provekit-0.6.0}/pyproject.toml +44 -7
  37. provekit-0.6.0/tests/test_admin.py +72 -0
  38. provekit-0.6.0/tests/test_alerts.py +55 -0
  39. {provekit-0.2.0 → provekit-0.6.0}/tests/test_auth.py +4 -2
  40. {provekit-0.2.0 → provekit-0.6.0}/tests/test_cov_limits_auth.py +13 -30
  41. provekit-0.6.0/tests/test_coverage_extra.py +295 -0
  42. provekit-0.6.0/tests/test_datasets.py +65 -0
  43. provekit-0.6.0/tests/test_eval.py +73 -0
  44. provekit-0.6.0/tests/test_experiments.py +51 -0
  45. provekit-0.6.0/tests/test_feedback.py +65 -0
  46. provekit-0.6.0/tests/test_hardening.py +49 -0
  47. provekit-0.6.0/tests/test_key_read_api.py +81 -0
  48. provekit-0.6.0/tests/test_mcp.py +61 -0
  49. provekit-0.6.0/tests/test_metrics.py +74 -0
  50. provekit-0.6.0/tests/test_projects.py +79 -0
  51. provekit-0.6.0/tests/test_redact.py +56 -0
  52. provekit-0.6.0/tests/test_scorers.py +32 -0
  53. provekit-0.6.0/tests/test_share.py +58 -0
  54. {provekit-0.2.0 → provekit-0.6.0}/tests/test_trace_sdk.py +78 -0
  55. provekit-0.2.0/provekit/config.py +0 -77
  56. provekit-0.2.0/provekit/models.py +0 -100
  57. provekit-0.2.0/provekit/routers/traces.py +0 -125
  58. provekit-0.2.0/provekit/services/__init__.py +0 -0
  59. provekit-0.2.0/provekit/services/limits.py +0 -109
  60. provekit-0.2.0/provekit/services/masking.py +0 -45
  61. provekit-0.2.0/provekit/services/runstore.py +0 -93
  62. provekit-0.2.0/provekit/services/sealing.py +0 -106
  63. provekit-0.2.0/provekit/services/workspace.py +0 -27
  64. provekit-0.2.0/provekit.egg-info/requires.txt +0 -26
  65. provekit-0.2.0/tests/test_runstore.py +0 -64
  66. {provekit-0.2.0 → provekit-0.6.0}/README.md +0 -0
  67. {provekit-0.2.0 → provekit-0.6.0}/provekit/database.py +0 -0
  68. {provekit-0.2.0 → provekit-0.6.0}/provekit/migrations/env.py +0 -0
  69. {provekit-0.2.0 → provekit-0.6.0}/provekit/migrations/script.py.mako +0 -0
  70. {provekit-0.2.0 → provekit-0.6.0}/provekit/migrations/versions/9d2c06bfa4e6_baseline.py +0 -0
  71. {provekit-0.2.0/provekit → provekit-0.6.0/provekit/routers}/__init__.py +0 -0
  72. {provekit-0.2.0 → provekit-0.6.0}/provekit/routers/apikeys.py +0 -0
  73. {provekit-0.2.0/provekit/routers → provekit-0.6.0/provekit/services}/__init__.py +0 -0
  74. {provekit-0.2.0 → provekit-0.6.0}/provekit/services/apikey.py +0 -0
  75. {provekit-0.2.0 → provekit-0.6.0}/provekit/services/auth.py +0 -0
  76. {provekit-0.2.0 → provekit-0.6.0}/provekit/services/deploy.py +0 -0
  77. {provekit-0.2.0 → provekit-0.6.0}/provekit/services/email.py +0 -0
  78. {provekit-0.2.0 → provekit-0.6.0}/provekit/services/netguard.py +0 -0
  79. {provekit-0.2.0 → provekit-0.6.0}/provekit.egg-info/dependency_links.txt +0 -0
  80. {provekit-0.2.0 → provekit-0.6.0}/provekit.egg-info/top_level.txt +0 -0
  81. {provekit-0.2.0 → provekit-0.6.0}/setup.cfg +0 -0
  82. {provekit-0.2.0 → provekit-0.6.0}/tests/test_account_recovery.py +0 -0
  83. {provekit-0.2.0 → provekit-0.6.0}/tests/test_apikeys.py +0 -0
  84. {provekit-0.2.0 → provekit-0.6.0}/tests/test_auth_hardening.py +0 -0
  85. {provekit-0.2.0 → provekit-0.6.0}/tests/test_autoinstrument.py +0 -0
  86. {provekit-0.2.0 → provekit-0.6.0}/tests/test_netguard.py +0 -0
  87. {provekit-0.2.0 → provekit-0.6.0}/tests/test_observability.py +0 -0
  88. {provekit-0.2.0 → provekit-0.6.0}/tests/test_otel.py +0 -0
  89. {provekit-0.2.0 → provekit-0.6.0}/tests/test_traces_tree.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: provekit
3
- Version: 0.2.0
3
+ Version: 0.6.0
4
4
  Summary: Drop-in agent tracing — add one decorator, get a project key, review every run your agent makes.
5
5
  Author: ProveKit contributors
6
6
  License: MIT
@@ -38,6 +38,29 @@ Requires-Dist: opentelemetry-api<2,>=1.20; extra == "trace"
38
38
  Requires-Dist: opentelemetry-sdk<2,>=1.20; extra == "trace"
39
39
  Requires-Dist: openinference-instrumentation-openai>=0.1; extra == "trace"
40
40
  Requires-Dist: openinference-instrumentation-anthropic>=0.1; extra == "trace"
41
+ Provides-Extra: http
42
+ Requires-Dist: opentelemetry-instrumentation-httpx>=0.40b0; extra == "http"
43
+ Requires-Dist: opentelemetry-instrumentation-requests>=0.40b0; extra == "http"
44
+ Requires-Dist: opentelemetry-instrumentation-urllib>=0.40b0; extra == "http"
45
+ Provides-Extra: trace-all
46
+ Requires-Dist: opentelemetry-api<2,>=1.20; extra == "trace-all"
47
+ Requires-Dist: opentelemetry-sdk<2,>=1.20; extra == "trace-all"
48
+ Requires-Dist: openinference-instrumentation-openai>=0.1; extra == "trace-all"
49
+ Requires-Dist: openinference-instrumentation-anthropic>=0.1; extra == "trace-all"
50
+ Requires-Dist: openinference-instrumentation-langchain>=0.1; extra == "trace-all"
51
+ Requires-Dist: openinference-instrumentation-llama-index>=0.1; extra == "trace-all"
52
+ Requires-Dist: openinference-instrumentation-crewai>=0.1; extra == "trace-all"
53
+ Requires-Dist: openinference-instrumentation-bedrock>=0.1; extra == "trace-all"
54
+ Requires-Dist: openinference-instrumentation-litellm>=0.1; extra == "trace-all"
55
+ Requires-Dist: openinference-instrumentation-groq>=0.1; extra == "trace-all"
56
+ Requires-Dist: openinference-instrumentation-mistralai>=0.1; extra == "trace-all"
57
+ Requires-Dist: openinference-instrumentation-dspy>=0.1; extra == "trace-all"
58
+ Requires-Dist: openinference-instrumentation-haystack>=0.1; extra == "trace-all"
59
+ Requires-Dist: opentelemetry-instrumentation-httpx>=0.40b0; extra == "trace-all"
60
+ Requires-Dist: opentelemetry-instrumentation-requests>=0.40b0; extra == "trace-all"
61
+ Requires-Dist: opentelemetry-instrumentation-urllib>=0.40b0; extra == "trace-all"
62
+ Provides-Extra: mcp
63
+ Requires-Dist: mcp>=1.2; extra == "mcp"
41
64
  Provides-Extra: dev
42
65
  Requires-Dist: pytest==9.1.1; extra == "dev"
43
66
  Requires-Dist: pytest-cov==6.0.0; extra == "dev"
@@ -0,0 +1,18 @@
1
+ """ProveKit — drop-in agent tracing. One SDK on the client captures the whole flow.
2
+
3
+ import provekit.trace as pk # the canonical import
4
+ pk.init() # or, zero-code: import provekit.auto
5
+
6
+ @pk.trace(name="my-agent") # optional: group one run under a named root
7
+ def run(q): ...
8
+
9
+ Everything beneath your code — LLM providers, agent frameworks, outbound HTTP — is
10
+ captured automatically. `init`, `configure`, and `span` are also re-exported here for
11
+ `import provekit as pk` convenience; the `trace` decorator lives on the `provekit.trace`
12
+ submodule (a same-named top-level export would shadow that module).
13
+ """
14
+ from provekit import scorers
15
+ from provekit.eval import evaluate
16
+ from provekit.trace import configure, init, span
17
+
18
+ __all__ = ["init", "configure", "span", "evaluate", "scorers"]
@@ -0,0 +1,11 @@
1
+ """Zero-code activation: `import provekit.auto` turns tracing on with no other calls.
2
+
3
+ import provekit.auto # reads PROVEKIT_API_KEY / PROVEKIT_ENDPOINT and instruments
4
+
5
+ Put this once at your program's entrypoint (before the libraries you want traced do their
6
+ work). Everything instrumented below it is then captured automatically. Fail-open: if the
7
+ env vars are unset or OpenTelemetry isn't installed, this is a silent no-op.
8
+ """
9
+ from provekit.trace import init
10
+
11
+ init()
@@ -0,0 +1,68 @@
1
+ """ProveKit configuration."""
2
+ from functools import lru_cache
3
+
4
+ from pydantic_settings import BaseSettings, SettingsConfigDict
5
+
6
+
7
+ class Settings(BaseSettings):
8
+ model_config = SettingsConfigDict(env_file=".env", extra="ignore")
9
+
10
+ database_url: str = "sqlite:///./provekit.db"
11
+ cors_origins: str = "http://localhost:3001,http://localhost:3000"
12
+
13
+ # Hosted mode: a login wall, secure cookies, and stricter outbound-URL guarding.
14
+ hosted: bool = False
15
+
16
+ # Signs session/reset/verify tokens (any string → a Fernet key). Optional for local
17
+ # SQLite (a key file is auto-generated); required for any other database.
18
+ secret_key: str = ""
19
+
20
+ # Optional OTLP/HTTP collector to mirror captured runs to another backend.
21
+ otel_export_url: str = ""
22
+
23
+ # Optional Sentry DSN for error reporting (off when unset).
24
+ sentry_dsn: str = ""
25
+
26
+ # Web app base URL (for links in emails).
27
+ web_base_url: str = "http://localhost:3001"
28
+
29
+ # Email (account recovery / verification). No SMTP host → links are logged, not sent.
30
+ smtp_host: str = ""
31
+ smtp_port: int = 587
32
+ smtp_user: str = ""
33
+ smtp_password: str = ""
34
+ smtp_from: str = ""
35
+ smtp_starttls: bool = True
36
+ require_email_verification: bool = False
37
+
38
+ # Optional Redis (shared rate-limit windows across workers). Unset → in-memory.
39
+ redis_url: str = ""
40
+
41
+ thread_pool_size: int = 200
42
+
43
+ # Mask PII (emails, cards, SSNs, phones, common secret keys) in captured span
44
+ # input/output/error before it's stored. Off by default; a safety net for hosted use.
45
+ redact_pii: bool = False
46
+
47
+ # Bootstrap platform operators: any account whose email is in this comma-list is treated
48
+ # as a superuser (admin console), even before the DB flag is set. e.g. "you@co.com".
49
+ superuser_emails: str = ""
50
+
51
+ @property
52
+ def superuser_email_list(self) -> list[str]:
53
+ return [e.strip().lower() for e in self.superuser_emails.split(",") if e.strip()]
54
+
55
+ # Quotas (protect shared infra). 0 disables a given limit.
56
+ ingest_rate_per_min: int = 600 # trace-ingest requests per project per minute
57
+ login_attempts_per_min: int = 10 # login attempts per email+IP per minute
58
+ runs_retention: int = 10000 # keep last N spans per project (older ones pruned)
59
+ max_body_bytes: int = 2_000_000 # reject request bodies larger than this
60
+
61
+ @property
62
+ def cors_origin_list(self) -> list[str]:
63
+ return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
64
+
65
+
66
+ @lru_cache
67
+ def get_settings() -> Settings:
68
+ return Settings()
@@ -0,0 +1,118 @@
1
+ """`provekit-demo` — a zero-setup smoke test.
2
+
3
+ Sends a small gallery of traces to your portal so a fresh install + project key can be
4
+ verified in ~10 seconds. No LLM key needed: the model is mocked, but recorded as a real
5
+ gen_ai LLM span (model, tokens, temperature) so the portal renders it as a proper chat call.
6
+
7
+ pip install "provekit[trace]"
8
+ export PROVEKIT_API_KEY=pk_... # a project key from the portal
9
+ export PROVEKIT_ENDPOINT=https://your-provekit-host
10
+ provekit-demo
11
+
12
+ For richer, framework-native examples (LangGraph, a deep multi-agent orchestrator) see the
13
+ examples/ folder: https://github.com/MobirizerServices/ProveKit/tree/main/examples
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import logging
19
+ import os
20
+ import time
21
+
22
+ from . import trace as pk
23
+
24
+ log = logging.getLogger("provekit-demo")
25
+
26
+ _KB = {
27
+ "refund": "I've started your refund — it lands in 3–5 business days.",
28
+ "vpn": "Forget the VPN profile and re-add it; that clears the stale auth token.",
29
+ "pricing": "The Pro plan is $49/seat/mo; annual billing saves ~15%.",
30
+ "export": "Settings → Data → Export builds a downloadable JSON of your workspace.",
31
+ }
32
+
33
+
34
+ def _mock_llm(span, model: str, prompt: str, temperature: float = 0.7) -> str:
35
+ """A deterministic, offline stand-in for an LLM — recorded as a real gen_ai LLM span."""
36
+ key = next((k for k in _KB if k in prompt.lower()), None)
37
+ answer = _KB.get(key, "Thanks for reaching out — a teammate will follow up shortly.")
38
+ if span:
39
+ span.set_attribute("gen_ai.request.model", model)
40
+ span.set_attribute("gen_ai.request.temperature", temperature)
41
+ span.set_attribute("gen_ai.input.messages", prompt)
42
+ span.set_attribute("gen_ai.usage.input_tokens", 40 + len(prompt) // 4)
43
+ span.set_attribute("gen_ai.usage.output_tokens", 12 + len(answer) // 4)
44
+ span.set_attribute("gen_ai.output.messages", answer)
45
+ span.set_attribute("gen_ai.response.finish_reasons", "stop")
46
+ time.sleep(0.01)
47
+ return answer
48
+
49
+
50
+ @pk.trace(name="support-agent")
51
+ def _support_agent(question: str, model: str = "gpt-4o") -> str:
52
+ with pk.span("retrieve") as s:
53
+ if s:
54
+ s.set_attribute("gen_ai.tool.name", "kb_search")
55
+ s.set_attribute("gen_ai.input.messages", question)
56
+ log.info("retrieved 3 candidate docs")
57
+ with pk.span("chat") as s:
58
+ answer = _mock_llm(s, model, question)
59
+ pk.score("helpfulness", score=0.9, comment="demo auto-score")
60
+ return answer
61
+
62
+
63
+ @pk.trace(name="support-agent", session_id="conv-demo-1")
64
+ def _support_turn(question: str) -> str:
65
+ with pk.span("chat") as s:
66
+ return _mock_llm(s, "gpt-4o-mini", question)
67
+
68
+
69
+ @pk.trace(name="flaky-agent")
70
+ def _flaky_agent(question: str) -> str:
71
+ with pk.span("call-upstream") as s:
72
+ if s:
73
+ s.set_attribute("gen_ai.tool.name", "billing_api")
74
+ raise RuntimeError("upstream billing_api returned 503")
75
+
76
+
77
+ def main(argv=None) -> None:
78
+ p = argparse.ArgumentParser(
79
+ prog="provekit-demo",
80
+ description="Send a small gallery of demo traces to your ProveKit portal (no LLM key needed).")
81
+ p.add_argument("--api-key", default=os.environ.get("PROVEKIT_API_KEY"),
82
+ help="project key (default: $PROVEKIT_API_KEY)")
83
+ p.add_argument("--endpoint", default=os.environ.get("PROVEKIT_ENDPOINT"),
84
+ help="portal URL (default: $PROVEKIT_ENDPOINT)")
85
+ args = p.parse_args(argv)
86
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
87
+
88
+ if not (args.api_key and args.endpoint):
89
+ raise SystemExit("Set PROVEKIT_API_KEY and PROVEKIT_ENDPOINT (or pass --api-key / --endpoint).\n"
90
+ "Grab a key from your portal → Project keys.")
91
+ if not pk.configure(api_key=args.api_key, endpoint=args.endpoint):
92
+ raise SystemExit("ProveKit could not start — check your key/endpoint.")
93
+
94
+ for q, model in (("I want a refund for my order", "gpt-4o"),
95
+ ("VPN won't connect after the update", "gpt-4o"),
96
+ ("What's your pricing for a 20-person team?", "claude-sonnet-5"),
97
+ ("How do I export my data?", "gpt-4o-mini")):
98
+ print("→", _support_agent(q, model=model)[:60])
99
+
100
+ # a two-turn conversation (same session) so sessions light up
101
+ _support_turn("Is the Pro plan monthly?")
102
+ _support_turn("And does annual billing save money?")
103
+
104
+ # a failed run so the dashboard error-rate + a red trace show up
105
+ try:
106
+ _flaky_agent("charge dispute")
107
+ except RuntimeError as exc:
108
+ print("✗ flaky-agent failed as designed:", exc)
109
+
110
+ from opentelemetry import trace as _t
111
+ provider = _t.get_tracer_provider()
112
+ if hasattr(provider, "force_flush"):
113
+ provider.force_flush()
114
+ print("\nDone — open your ProveKit portal:", args.endpoint.rstrip("/") + "/traces")
115
+
116
+
117
+ if __name__ == "__main__":
118
+ main()
@@ -0,0 +1,74 @@
1
+ """Offline evaluation from the client: run a target over a dataset, score each output, and
2
+ record it as an experiment in your portal — the basis of a CI regression gate.
3
+
4
+ import provekit as pk
5
+
6
+ def target(question: str) -> str:
7
+ return my_agent(question)
8
+
9
+ summary = pk.evaluate("qa-golden", target, scorers=["exact_match", "contains"])
10
+ assert summary["mean_score"] >= 0.8 # fail the build on a regression
11
+
12
+ `scorers` are names from provekit.scorers (exact_match/contains/regex_match/json_valid) or
13
+ your own `fn(output, expected) -> float`. The dataset is a name or id from your portal.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+
19
+ import httpx
20
+
21
+ from . import scorers as _scorers
22
+ from . import trace as _trace
23
+
24
+ _TIMEOUT = 60
25
+
26
+
27
+ def _get(endpoint, headers, path):
28
+ r = httpx.get(f"{endpoint}{path}", headers=headers, timeout=_TIMEOUT, follow_redirects=False)
29
+ r.raise_for_status()
30
+ return r.json()
31
+
32
+
33
+ def _post(endpoint, headers, path, body):
34
+ r = httpx.post(f"{endpoint}{path}", headers=headers, json=body, timeout=_TIMEOUT, follow_redirects=False)
35
+ r.raise_for_status()
36
+ return r.json()
37
+
38
+
39
+ def _resolve_dataset_id(endpoint, headers, dataset) -> int:
40
+ if isinstance(dataset, int):
41
+ return dataset
42
+ for d in _get(endpoint, headers, "/v1/datasets"):
43
+ if d.get("name") == dataset:
44
+ return d["id"]
45
+ raise ValueError(f"dataset {dataset!r} not found")
46
+
47
+
48
+ def evaluate(dataset, target, scorers, *, name: str = "experiment") -> dict:
49
+ """Run `target(input)` over every item in `dataset`, score each output with `scorers`,
50
+ post the results as a new experiment, and return its summary (id, result_count,
51
+ scorer_means, mean_score). Raises if ProveKit isn't configured."""
52
+ if not _trace.configure():
53
+ raise RuntimeError("ProveKit not configured: set PROVEKIT_API_KEY and PROVEKIT_ENDPOINT")
54
+ endpoint, headers = _trace._endpoint, {"Authorization": f"Bearer {_trace._api_key}"}
55
+
56
+ dsid = _resolve_dataset_id(endpoint, headers, dataset)
57
+ items = _get(endpoint, headers, f"/v1/datasets/{dsid}/items")
58
+ exp = _post(endpoint, headers, "/v1/experiments", {"name": name, "dataset_id": dsid})
59
+ eid = exp["id"]
60
+
61
+ for it in items:
62
+ try:
63
+ output = target(it["input"])
64
+ except Exception as exc: # a target failure is a data point, not a crash
65
+ output = f"ERROR: {exc}"
66
+ if not isinstance(output, str):
67
+ output = json.dumps(output, default=str)
68
+ expected = it.get("expected", "")
69
+ scores = _scorers.run_scorers(scorers, output, expected)
70
+ _post(endpoint, headers, f"/v1/experiments/{eid}/results",
71
+ {"item_id": it.get("id"), "input": it["input"], "output": output,
72
+ "expected": expected, "scores": scores})
73
+
74
+ return _get(endpoint, headers, f"/v1/experiments/{eid}")
@@ -16,7 +16,7 @@ from .observability import (
16
16
  init_sentry,
17
17
  setup_logging,
18
18
  )
19
- from .routers import apikeys, auth, traces
19
+ from .routers import admin, alerts, apikeys, auth, datasets, experiments, metrics, projects, traces
20
20
 
21
21
  logging.basicConfig(level=logging.INFO)
22
22
  settings = get_settings()
@@ -81,6 +81,14 @@ app.include_router(apikeys.router)
81
81
  app.include_router(traces.router)
82
82
  app.include_router(traces.ws_router)
83
83
  app.include_router(traces.runs_router)
84
+ app.include_router(datasets.router)
85
+ app.include_router(datasets.key_router)
86
+ app.include_router(experiments.router)
87
+ app.include_router(experiments.key_router)
88
+ app.include_router(metrics.router)
89
+ app.include_router(alerts.router)
90
+ app.include_router(projects.router)
91
+ app.include_router(admin.router)
84
92
 
85
93
 
86
94
  @app.get("/")
@@ -0,0 +1,83 @@
1
+ """ProveKit MCP server — debug your traces from any MCP client (Claude Desktop, Cursor, …).
2
+
3
+ This is a *debug channel*, not a second client SDK: your agent still installs only the one
4
+ `provekit` tracing SDK. This server runs wherever you like (your laptop, CI) and reads the
5
+ already-captured traces from your portal over HTTP, authenticated by the same project key.
6
+
7
+ pip install "provekit[mcp]"
8
+ export PROVEKIT_API_KEY=pk_... # your project key
9
+ export PROVEKIT_ENDPOINT=https://provekit.your-co.com
10
+ provekit-mcp # speaks MCP over stdio
11
+
12
+ Then point an MCP client at the `provekit-mcp` command. It exposes tools to list recent
13
+ traces, surface failures, and pull a full span tree — so the assistant can reason over an
14
+ agent run and tell you what broke, with no code added to the traced app.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import os
19
+
20
+ import httpx
21
+
22
+ _TIMEOUT = 15
23
+
24
+
25
+ def _base() -> tuple[str, dict]:
26
+ """Resolve the portal URL + auth header from the environment, or raise a clear error."""
27
+ key = os.environ.get("PROVEKIT_API_KEY")
28
+ endpoint = os.environ.get("PROVEKIT_ENDPOINT")
29
+ if not key or not endpoint:
30
+ raise RuntimeError(
31
+ "Set PROVEKIT_API_KEY (your pk_ project key) and PROVEKIT_ENDPOINT "
32
+ "(your portal URL) before starting the ProveKit MCP server."
33
+ )
34
+ return endpoint.rstrip("/"), {"Authorization": f"Bearer {key}"}
35
+
36
+
37
+ def _get(path: str, **params):
38
+ url, headers = _base()
39
+ r = httpx.get(f"{url}/v1{path}", headers=headers, params=params, timeout=_TIMEOUT,
40
+ follow_redirects=False)
41
+ r.raise_for_status()
42
+ return r.json()
43
+
44
+
45
+ # ---- the callable data layer (import-safe; used by the MCP tools and by tests) ----
46
+ def list_traces(limit: int = 20, status: str | None = None) -> list[dict]:
47
+ """Recent traces, newest first. Pass status='failed' to see only failed runs."""
48
+ return _get("/traces", limit=limit, **({"status": status} if status else {}))
49
+
50
+
51
+ def list_failures(limit: int = 20) -> list[dict]:
52
+ """Just the traces that ended in a failure — the ones worth debugging first."""
53
+ return list_traces(limit=limit, status="failed")
54
+
55
+
56
+ def get_trace(trace_id: str) -> list[dict]:
57
+ """Every span of one trace (the full nested flow), in start order."""
58
+ return _get(f"/traces/{trace_id}")
59
+
60
+
61
+ def build_server(): # pragma: no cover - needs the optional `mcp` dep; glue over the tested data layer
62
+ """Construct the FastMCP server with the trace tools registered. Imported lazily so the
63
+ module stays importable (and testable) without the optional `mcp` dependency. The three
64
+ data-layer functions above are registered directly as MCP tools."""
65
+ from mcp.server.fastmcp import FastMCP
66
+
67
+ server = FastMCP("provekit")
68
+ server.add_tool(list_traces, name="provekit_list_traces",
69
+ description="List recent ProveKit traces (agent runs). status='failed' filters to failures.")
70
+ server.add_tool(list_failures, name="provekit_list_failures",
71
+ description="List recent traces that ended in a failure — start debugging here.")
72
+ server.add_tool(get_trace, name="provekit_get_trace",
73
+ description="Fetch the full span tree of one trace by trace_id to inspect what happened.")
74
+ return server
75
+
76
+
77
+ def main() -> None: # pragma: no cover - blocks serving MCP over stdio; exercised by hand
78
+ """Console-script entrypoint: `provekit-mcp`. Speaks MCP over stdio."""
79
+ build_server().run()
80
+
81
+
82
+ if __name__ == "__main__": # pragma: no cover
83
+ main()
@@ -0,0 +1,49 @@
1
+ """feedback table + runs.session_id
2
+
3
+ Revision ID: a1b2c3d4e5f6
4
+ Revises: 9d2c06bfa4e6
5
+ Create Date: 2026-07-19 00:00:00.000000
6
+ """
7
+ from alembic import op
8
+ import sqlalchemy as sa
9
+
10
+
11
+ revision = 'a1b2c3d4e5f6'
12
+ down_revision = '9d2c06bfa4e6'
13
+ branch_labels = None
14
+ depends_on = None
15
+
16
+
17
+ def upgrade() -> None:
18
+ op.create_table('feedback',
19
+ sa.Column('id', sa.Integer(), nullable=False),
20
+ sa.Column('workspace_id', sa.Integer(), nullable=True),
21
+ sa.Column('trace_id', sa.String(length=32), nullable=False),
22
+ sa.Column('name', sa.String(length=120), nullable=False),
23
+ sa.Column('score', sa.Float(), nullable=True),
24
+ sa.Column('value', sa.String(length=200), nullable=False),
25
+ sa.Column('comment', sa.Text(), nullable=False),
26
+ sa.Column('source', sa.String(length=16), nullable=False),
27
+ sa.Column('created_at', sa.DateTime(), nullable=False),
28
+ sa.ForeignKeyConstraint(['workspace_id'], ['workspaces.id'], name=op.f('fk_feedback_workspace_id_workspaces')),
29
+ sa.PrimaryKeyConstraint('id', name=op.f('pk_feedback'))
30
+ )
31
+ with op.batch_alter_table('feedback', schema=None) as batch_op:
32
+ batch_op.create_index(batch_op.f('ix_feedback_trace_id'), ['trace_id'], unique=False)
33
+ batch_op.create_index(batch_op.f('ix_feedback_workspace_id'), ['workspace_id'], unique=False)
34
+
35
+ with op.batch_alter_table('runs', schema=None) as batch_op:
36
+ batch_op.add_column(sa.Column('session_id', sa.String(length=64), nullable=False,
37
+ server_default=''))
38
+ batch_op.create_index(batch_op.f('ix_runs_session_id'), ['session_id'], unique=False)
39
+
40
+
41
+ def downgrade() -> None:
42
+ with op.batch_alter_table('runs', schema=None) as batch_op:
43
+ batch_op.drop_index(batch_op.f('ix_runs_session_id'))
44
+ batch_op.drop_column('session_id')
45
+
46
+ with op.batch_alter_table('feedback', schema=None) as batch_op:
47
+ batch_op.drop_index(batch_op.f('ix_feedback_workspace_id'))
48
+ batch_op.drop_index(batch_op.f('ix_feedback_trace_id'))
49
+ op.drop_table('feedback')
@@ -0,0 +1,54 @@
1
+ """datasets + dataset_items
2
+
3
+ Revision ID: b2c3d4e5f6a7
4
+ Revises: a1b2c3d4e5f6
5
+ Create Date: 2026-07-19 01:00:00.000000
6
+ """
7
+ from alembic import op
8
+ import sqlalchemy as sa
9
+
10
+
11
+ revision = 'b2c3d4e5f6a7'
12
+ down_revision = 'a1b2c3d4e5f6'
13
+ branch_labels = None
14
+ depends_on = None
15
+
16
+
17
+ def upgrade() -> None:
18
+ op.create_table('datasets',
19
+ sa.Column('id', sa.Integer(), nullable=False),
20
+ sa.Column('workspace_id', sa.Integer(), nullable=True),
21
+ sa.Column('name', sa.String(length=160), nullable=False),
22
+ sa.Column('description', sa.Text(), nullable=False),
23
+ sa.Column('created_at', sa.DateTime(), nullable=False),
24
+ sa.ForeignKeyConstraint(['workspace_id'], ['workspaces.id'], name=op.f('fk_datasets_workspace_id_workspaces')),
25
+ sa.PrimaryKeyConstraint('id', name=op.f('pk_datasets'))
26
+ )
27
+ with op.batch_alter_table('datasets', schema=None) as batch_op:
28
+ batch_op.create_index(batch_op.f('ix_datasets_workspace_id'), ['workspace_id'], unique=False)
29
+
30
+ op.create_table('dataset_items',
31
+ sa.Column('id', sa.Integer(), nullable=False),
32
+ sa.Column('workspace_id', sa.Integer(), nullable=True),
33
+ sa.Column('dataset_id', sa.Integer(), nullable=False),
34
+ sa.Column('input', sa.Text(), nullable=False),
35
+ sa.Column('expected', sa.Text(), nullable=False),
36
+ sa.Column('meta', sa.JSON(), nullable=False),
37
+ sa.Column('created_at', sa.DateTime(), nullable=False),
38
+ sa.ForeignKeyConstraint(['dataset_id'], ['datasets.id'], name=op.f('fk_dataset_items_dataset_id_datasets')),
39
+ sa.ForeignKeyConstraint(['workspace_id'], ['workspaces.id'], name=op.f('fk_dataset_items_workspace_id_workspaces')),
40
+ sa.PrimaryKeyConstraint('id', name=op.f('pk_dataset_items'))
41
+ )
42
+ with op.batch_alter_table('dataset_items', schema=None) as batch_op:
43
+ batch_op.create_index(batch_op.f('ix_dataset_items_dataset_id'), ['dataset_id'], unique=False)
44
+ batch_op.create_index(batch_op.f('ix_dataset_items_workspace_id'), ['workspace_id'], unique=False)
45
+
46
+
47
+ def downgrade() -> None:
48
+ with op.batch_alter_table('dataset_items', schema=None) as batch_op:
49
+ batch_op.drop_index(batch_op.f('ix_dataset_items_workspace_id'))
50
+ batch_op.drop_index(batch_op.f('ix_dataset_items_dataset_id'))
51
+ op.drop_table('dataset_items')
52
+ with op.batch_alter_table('datasets', schema=None) as batch_op:
53
+ batch_op.drop_index(batch_op.f('ix_datasets_workspace_id'))
54
+ op.drop_table('datasets')
@@ -0,0 +1,59 @@
1
+ """experiments + experiment_results
2
+
3
+ Revision ID: c3d4e5f6a7b8
4
+ Revises: b2c3d4e5f6a7
5
+ Create Date: 2026-07-19 02:00:00.000000
6
+ """
7
+ from alembic import op
8
+ import sqlalchemy as sa
9
+
10
+
11
+ revision = 'c3d4e5f6a7b8'
12
+ down_revision = 'b2c3d4e5f6a7'
13
+ branch_labels = None
14
+ depends_on = None
15
+
16
+
17
+ def upgrade() -> None:
18
+ op.create_table('experiments',
19
+ sa.Column('id', sa.Integer(), nullable=False),
20
+ sa.Column('workspace_id', sa.Integer(), nullable=True),
21
+ sa.Column('name', sa.String(length=160), nullable=False),
22
+ sa.Column('dataset_id', sa.Integer(), nullable=True),
23
+ sa.Column('created_at', sa.DateTime(), nullable=False),
24
+ sa.ForeignKeyConstraint(['dataset_id'], ['datasets.id'], name=op.f('fk_experiments_dataset_id_datasets')),
25
+ sa.ForeignKeyConstraint(['workspace_id'], ['workspaces.id'], name=op.f('fk_experiments_workspace_id_workspaces')),
26
+ sa.PrimaryKeyConstraint('id', name=op.f('pk_experiments'))
27
+ )
28
+ with op.batch_alter_table('experiments', schema=None) as batch_op:
29
+ batch_op.create_index(batch_op.f('ix_experiments_dataset_id'), ['dataset_id'], unique=False)
30
+ batch_op.create_index(batch_op.f('ix_experiments_workspace_id'), ['workspace_id'], unique=False)
31
+
32
+ op.create_table('experiment_results',
33
+ sa.Column('id', sa.Integer(), nullable=False),
34
+ sa.Column('workspace_id', sa.Integer(), nullable=True),
35
+ sa.Column('experiment_id', sa.Integer(), nullable=False),
36
+ sa.Column('item_id', sa.Integer(), nullable=True),
37
+ sa.Column('input', sa.Text(), nullable=False),
38
+ sa.Column('output', sa.Text(), nullable=False),
39
+ sa.Column('expected', sa.Text(), nullable=False),
40
+ sa.Column('scores', sa.JSON(), nullable=False),
41
+ sa.Column('created_at', sa.DateTime(), nullable=False),
42
+ sa.ForeignKeyConstraint(['experiment_id'], ['experiments.id'], name=op.f('fk_experiment_results_experiment_id_experiments')),
43
+ sa.ForeignKeyConstraint(['workspace_id'], ['workspaces.id'], name=op.f('fk_experiment_results_workspace_id_workspaces')),
44
+ sa.PrimaryKeyConstraint('id', name=op.f('pk_experiment_results'))
45
+ )
46
+ with op.batch_alter_table('experiment_results', schema=None) as batch_op:
47
+ batch_op.create_index(batch_op.f('ix_experiment_results_experiment_id'), ['experiment_id'], unique=False)
48
+ batch_op.create_index(batch_op.f('ix_experiment_results_workspace_id'), ['workspace_id'], unique=False)
49
+
50
+
51
+ def downgrade() -> None:
52
+ with op.batch_alter_table('experiment_results', schema=None) as batch_op:
53
+ batch_op.drop_index(batch_op.f('ix_experiment_results_workspace_id'))
54
+ batch_op.drop_index(batch_op.f('ix_experiment_results_experiment_id'))
55
+ op.drop_table('experiment_results')
56
+ with op.batch_alter_table('experiments', schema=None) as batch_op:
57
+ batch_op.drop_index(batch_op.f('ix_experiments_workspace_id'))
58
+ batch_op.drop_index(batch_op.f('ix_experiments_dataset_id'))
59
+ op.drop_table('experiments')
@@ -0,0 +1,40 @@
1
+ """alerts
2
+
3
+ Revision ID: d4e5f6a7b8c9
4
+ Revises: c3d4e5f6a7b8
5
+ Create Date: 2026-07-19 03:00:00.000000
6
+ """
7
+ from alembic import op
8
+ import sqlalchemy as sa
9
+
10
+
11
+ revision = 'd4e5f6a7b8c9'
12
+ down_revision = 'c3d4e5f6a7b8'
13
+ branch_labels = None
14
+ depends_on = None
15
+
16
+
17
+ def upgrade() -> None:
18
+ op.create_table('alerts',
19
+ sa.Column('id', sa.Integer(), nullable=False),
20
+ sa.Column('workspace_id', sa.Integer(), nullable=True),
21
+ sa.Column('name', sa.String(length=160), nullable=False),
22
+ sa.Column('metric', sa.String(length=32), nullable=False),
23
+ sa.Column('comparator', sa.String(length=4), nullable=False),
24
+ sa.Column('threshold', sa.Float(), nullable=False),
25
+ sa.Column('window_hours', sa.Integer(), nullable=False),
26
+ sa.Column('email', sa.String(length=255), nullable=False),
27
+ sa.Column('enabled', sa.Boolean(), nullable=False),
28
+ sa.Column('last_triggered_at', sa.DateTime(), nullable=True),
29
+ sa.Column('created_at', sa.DateTime(), nullable=False),
30
+ sa.ForeignKeyConstraint(['workspace_id'], ['workspaces.id'], name=op.f('fk_alerts_workspace_id_workspaces')),
31
+ sa.PrimaryKeyConstraint('id', name=op.f('pk_alerts'))
32
+ )
33
+ with op.batch_alter_table('alerts', schema=None) as batch_op:
34
+ batch_op.create_index(batch_op.f('ix_alerts_workspace_id'), ['workspace_id'], unique=False)
35
+
36
+
37
+ def downgrade() -> None:
38
+ with op.batch_alter_table('alerts', schema=None) as batch_op:
39
+ batch_op.drop_index(batch_op.f('ix_alerts_workspace_id'))
40
+ op.drop_table('alerts')