opengrade-ai 0.1.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 (49) hide show
  1. opengrade_ai-0.1.0/.gitignore +42 -0
  2. opengrade_ai-0.1.0/PKG-INFO +487 -0
  3. opengrade_ai-0.1.0/README.md +453 -0
  4. opengrade_ai-0.1.0/hatch_build.py +22 -0
  5. opengrade_ai-0.1.0/pyproject.toml +86 -0
  6. opengrade_ai-0.1.0/src/opengrade/__init__.py +85 -0
  7. opengrade_ai-0.1.0/src/opengrade/_async.py +44 -0
  8. opengrade_ai-0.1.0/src/opengrade/_config.py +124 -0
  9. opengrade_ai-0.1.0/src/opengrade/_context.py +186 -0
  10. opengrade_ai-0.1.0/src/opengrade/_eval_run.py +35 -0
  11. opengrade_ai-0.1.0/src/opengrade/_http.py +256 -0
  12. opengrade_ai-0.1.0/src/opengrade/_links.py +40 -0
  13. opengrade_ai-0.1.0/src/opengrade/_live.py +106 -0
  14. opengrade_ai-0.1.0/src/opengrade/_otel.py +296 -0
  15. opengrade_ai-0.1.0/src/opengrade/_production.py +484 -0
  16. opengrade_ai-0.1.0/src/opengrade/_run_context.py +61 -0
  17. opengrade_ai-0.1.0/src/opengrade/_scores.py +267 -0
  18. opengrade_ai-0.1.0/src/opengrade/_trace.py +264 -0
  19. opengrade_ai-0.1.0/src/opengrade/async_client.py +183 -0
  20. opengrade_ai-0.1.0/src/opengrade/cli.py +24 -0
  21. opengrade_ai-0.1.0/src/opengrade/client.py +615 -0
  22. opengrade_ai-0.1.0/src/opengrade/experiment.py +1184 -0
  23. opengrade_ai-0.1.0/src/opengrade/feedback.py +315 -0
  24. opengrade_ai-0.1.0/src/opengrade/instrumentation.py +88 -0
  25. opengrade_ai-0.1.0/src/opengrade/metrics.py +233 -0
  26. opengrade_ai-0.1.0/src/opengrade/models.py +292 -0
  27. opengrade_ai-0.1.0/src/opengrade/registry.py +109 -0
  28. opengrade_ai-0.1.0/tests/conftest.py +3 -0
  29. opengrade_ai-0.1.0/tests/test_async.py +40 -0
  30. opengrade_ai-0.1.0/tests/test_async_client.py +264 -0
  31. opengrade_ai-0.1.0/tests/test_cli.py +41 -0
  32. opengrade_ai-0.1.0/tests/test_client.py +346 -0
  33. opengrade_ai-0.1.0/tests/test_config.py +49 -0
  34. opengrade_ai-0.1.0/tests/test_context.py +254 -0
  35. opengrade_ai-0.1.0/tests/test_eval_bind.py +90 -0
  36. opengrade_ai-0.1.0/tests/test_experiment.py +817 -0
  37. opengrade_ai-0.1.0/tests/test_feedback.py +346 -0
  38. opengrade_ai-0.1.0/tests/test_http.py +117 -0
  39. opengrade_ai-0.1.0/tests/test_instrumentation.py +77 -0
  40. opengrade_ai-0.1.0/tests/test_links.py +20 -0
  41. opengrade_ai-0.1.0/tests/test_live.py +204 -0
  42. opengrade_ai-0.1.0/tests/test_metrics.py +189 -0
  43. opengrade_ai-0.1.0/tests/test_otel.py +148 -0
  44. opengrade_ai-0.1.0/tests/test_production_metrics.py +932 -0
  45. opengrade_ai-0.1.0/tests/test_registry.py +146 -0
  46. opengrade_ai-0.1.0/tests/test_rename_contract.py +21 -0
  47. opengrade_ai-0.1.0/tests/test_scores.py +250 -0
  48. opengrade_ai-0.1.0/tests/test_trace.py +132 -0
  49. opengrade_ai-0.1.0/uv.lock +3504 -0
@@ -0,0 +1,42 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ .venv/
8
+ venv/
9
+ .env
10
+ .env.local
11
+ *.egg-info/
12
+ dist/
13
+ build/
14
+ .mypy_cache/
15
+ .ruff_cache/
16
+ .pytest_cache/
17
+ .coverage
18
+ htmlcov/
19
+ .uv/
20
+
21
+ # IDE / OS
22
+ .idea/
23
+ .vscode/
24
+ *.swp
25
+ .DS_Store
26
+ Thumbs.db
27
+
28
+ # Docker volumes
29
+ postgres_data/
30
+
31
+ .worktrees/
32
+ .cursor/
33
+ docs/superpowers/
34
+
35
+ # Packaged Next.js UI (built by scripts/package-ui.sh)
36
+ backend/src/opengrade_server/_ui/**
37
+ !backend/src/opengrade_server/_ui/.gitkeep
38
+
39
+ opengrade.db
40
+ **/opengrade.db
41
+
42
+ .opengrade/
@@ -0,0 +1,487 @@
1
+ Metadata-Version: 2.5
2
+ Name: opengrade-ai
3
+ Version: 0.1.0
4
+ Summary: Python SDK for AI Evaluation & Observability Platform (OTLP + OpenInference)
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: httpx2>=2.13.0
7
+ Requires-Dist: opentelemetry-api>=1.28.0
8
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.28.0
9
+ Requires-Dist: opentelemetry-sdk>=1.28.0
10
+ Requires-Dist: pydantic>=2.10.0
11
+ Provides-Extra: ai
12
+ Requires-Dist: openinference-instrumentation-anthropic>=0.1.0; extra == 'ai'
13
+ Requires-Dist: openinference-instrumentation-bedrock>=0.1.0; extra == 'ai'
14
+ Requires-Dist: openinference-instrumentation-langchain>=0.1.0; extra == 'ai'
15
+ Requires-Dist: openinference-instrumentation-llama-index>=0.1.0; extra == 'ai'
16
+ Requires-Dist: openinference-instrumentation-openai>=0.1.0; extra == 'ai'
17
+ Provides-Extra: anthropic
18
+ Requires-Dist: openinference-instrumentation-anthropic>=0.1.0; extra == 'anthropic'
19
+ Provides-Extra: bedrock
20
+ Requires-Dist: openinference-instrumentation-bedrock>=0.1.0; extra == 'bedrock'
21
+ Provides-Extra: dev
22
+ Requires-Dist: mypy>=1.13.0; extra == 'dev'
23
+ Requires-Dist: pytest>=8.3.0; extra == 'dev'
24
+ Requires-Dist: ruff>=0.8.0; extra == 'dev'
25
+ Provides-Extra: langchain
26
+ Requires-Dist: openinference-instrumentation-langchain>=0.1.0; extra == 'langchain'
27
+ Provides-Extra: llama-index
28
+ Requires-Dist: openinference-instrumentation-llama-index>=0.1.0; extra == 'llama-index'
29
+ Provides-Extra: openai
30
+ Requires-Dist: openinference-instrumentation-openai>=0.1.0; extra == 'openai'
31
+ Provides-Extra: ui
32
+ Requires-Dist: opengrade-ai-server==0.1.0; extra == 'ui'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # opengrade
36
+
37
+ Python SDK for the AI Evaluation & Observability Platform: OTLP/HTTP export with optional OpenInference instrumentation, plus a control-plane `Client` for datasets, experiments, and evaluation.
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pip install opengrade-ai
43
+ pip install 'opengrade-ai[openai]'
44
+ pip install 'opengrade-ai[ai]' # all tier-1 OpenInference instrumentors
45
+ pip install 'opengrade-ai[ui]' # also installs opengrade-ai-server (API + packaged UI)
46
+ ```
47
+
48
+ Individual extras: `openai`, `anthropic`, `langchain`, `llama-index`, `bedrock`, `ui`.
49
+
50
+ Import path remains `import opengrade`. Local platform (SQLite + UI):
51
+
52
+ ```bash
53
+ pip install 'opengrade-ai[ui]'
54
+ opengrade ui # http://127.0.0.1:8000 — creates ./opengrade.db
55
+ ```
56
+
57
+ The `opengrade` command ships with the SDK. Without the `[ui]` extra it exits with a hint to install it.
58
+
59
+ Develop from this monorepo:
60
+
61
+ ```bash
62
+ cd sdk && uv sync --extra dev
63
+ uv sync --extra openai # add OpenAI instrumentor
64
+ # UI packaging (server wheel): ./scripts/package-ui.sh then cd backend && uv sync
65
+ ```
66
+
67
+ ## Quickstart
68
+
69
+ One tracking URI, one active project, one `init`. A `@trace` wrapper for your app logic, and provider calls become child spans when the matching instrumentor is installed:
70
+
71
+ ```python
72
+ import opengrade
73
+ from openai import OpenAI
74
+
75
+ opengrade.set_tracking_uri("http://localhost:8000") # or OPENGRADE_TRACKING_URI
76
+ opengrade.set_project("demo") # created if it does not exist
77
+ opengrade.init(instrument="auto") # OTLP → <tracking_uri>/v1/traces
78
+
79
+ client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
80
+
81
+
82
+ @opengrade.trace
83
+ def ask(q: str) -> str:
84
+ r = client.chat.completions.create(
85
+ model="gemma4:e2b",
86
+ messages=[{"role": "user", "content": q}],
87
+ )
88
+ return r.choices[0].message.content or ""
89
+
90
+
91
+ ask("ping")
92
+ opengrade.flush()
93
+ ```
94
+
95
+ Runnable example: [`examples/sdk_hello/`](../examples/sdk_hello/README.md) (Ollama via OpenAI-compatible API).
96
+
97
+ ### API summary
98
+
99
+ | Function | Purpose |
100
+ |---|---|
101
+ | `opengrade.set_tracking_uri(uri)` | Platform URL for both `Client` and OTLP (`<uri>/v1/traces`) |
102
+ | `opengrade.set_project(name)` | Get-or-create project by slug/name; becomes the default for `init()` |
103
+ | `opengrade.aset_project(name)` | Async variant of `set_project` (uses shared `AsyncClient`) |
104
+ | `opengrade.get_client()` | Shared `Client` bound to the tracking URI (no per-call `Client()`) |
105
+ | `opengrade.get_async_client()` | Shared `AsyncClient` for async app code |
106
+ | `opengrade.get_project()` | Project set via `set_project` / `aset_project`, or `None` |
107
+ | `opengrade.init(...)` | OTLP exporter, project headers, optional `instrument="auto"` \| list \| `False` |
108
+ | `@opengrade.trace` / `@opengrade.trace_async` | Parent CHAIN span; optional input/output capture (32 KiB cap) |
109
+ | `opengrade.set_input` / `set_output` | Override OpenInference I/O on the current span (no OTEL import) |
110
+ | `opengrade.set_attribute` / `set_attributes` | Custom attributes on the current span |
111
+ | `opengrade.set_error(...)` | Mark current span ERROR |
112
+ | `opengrade.current_trace_id()` | 32-char hex trace id while inside a `@trace` span |
113
+ | `opengrade.bind_evaluation(...)` | Bind current span to `experiment_id` + `dataset_item_id` |
114
+ | `opengrade.set_retrieval_documents([...])` | Attach retrieval docs for RAG eval context |
115
+ | `opengrade.flush()` | Drain the batch span processor before exit |
116
+ | `opengrade.evaluate(...)` | Run a task over a dataset, score, compare with a baseline → `ExperimentResult` |
117
+ | `opengrade.start_experiment(...)` | Context manager for a manual item loop (`exp.items()`, `exp.run(item)`) |
118
+ | `opengrade.Client(...)` | Control-plane REST client (projects, datasets, experiments, …) |
119
+ | `opengrade.AsyncClient(...)` | Async control-plane client (projects, live_runs, trace_scores, health) |
120
+
121
+ App code should use these helpers instead of importing OpenTelemetry directly.
122
+
123
+ ## Experiments (`evaluate` / `start_experiment`)
124
+
125
+ One call runs a task over a dataset, writes the outputs, scores them and compares against a baseline:
126
+
127
+ ```python
128
+ import opengrade
129
+
130
+ opengrade.set_project("demo")
131
+ opengrade.init() # optional: adds trace context (tokens, cost, tool calls) to each item
132
+
133
+
134
+ def answer_question(item): # item.input, item.expected_output, item.metadata
135
+ docs = retrieve(item.input)
136
+ opengrade.set_retrieval_documents(docs) # feeds hit_at_k / recall_at_k / groundedness
137
+ return generate(item.input, docs)
138
+
139
+
140
+ def short(item, output) -> bool: # local metric: float | bool | None
141
+ return len(output) < 400
142
+
143
+
144
+ result = opengrade.evaluate(
145
+ dataset="gold-v1", # name/id, path, list/iterable of dicts, pandas/Polars DataFrame
146
+ task=answer_question,
147
+ metrics=["groundedness", "hit_at_k", short],
148
+ name="gemma-prompt-v2",
149
+ params={"model": "gemma4"}, # stored as the experiment's model_config
150
+ baseline="prod", # experiment name (latest) or id
151
+ max_concurrency=8,
152
+ )
153
+ print(result.summary(), result.url)
154
+ ```
155
+
156
+ Manual loop, same plumbing (binding, flush, output upload, scoring on exit):
157
+
158
+ ```python
159
+ with opengrade.start_experiment("v2", dataset="gold-v1", metrics=["hit_at_k"]) as exp:
160
+ for item in exp.items():
161
+ with exp.run(item) as run: # root span bound to (experiment, item)
162
+ run.log(my_app(item.input)) # or opengrade.set_output(...) anywhere inside
163
+ print(exp.result.summary())
164
+ ```
165
+
166
+ How it behaves:
167
+
168
+ - **Metrics**: a string is a server metric kind (`hit_at_k`, `recall_at_k`, `mrr`, `must_contain`, `groundedness`, `correctness`, `answer_relevance`, `context_precision`, `context_recall`, `latency`). A dict sets threshold/config, for example `{"kind": "hit_at_k", "config": {"k": 3}}`. The SDK finds or creates a metrics set that holds exactly those kinds, using the project's defaults for anything you leave out. `metrics=None` uses the project default set. A Python metric runs locally (see *Custom metrics* below), and its scores are submitted to the platform (`POST /experiments/{id}/client-results`). From there they are aggregated, shown in the UI and compared with the baseline like server metrics.
169
+ - **Datasets**: pass a registered name/id, a path to CSV/JSON/JSONL, a list or iterable of dicts, a pandas DataFrame, or a Polars DataFrame. Rows need an `input` column (or `question`). `expected_output` (or `expected_answer`), `context` and `metadata` map to item fields; any other column goes into `metadata` (so `expected_doc_ids` and `must_contain` work). A missing `input`/`question` raises `opengrade.DatasetColumnError` (CSV headers are checked up front). Inline rows are hashed into a dataset name, so re-running on the same rows reuses the same dataset. Generators are materialised before upload (no lazy streaming).
170
+ - **Outputs** are uploaded with `PUT /experiments/{id}/outputs`, so this works without `opengrade.init()`. With tracing on, `finish()` waits for the OTLP traces to be bound (up to 30s) and merges their context in. Your explicit output always wins.
171
+ - **Errors**: when a task raises, `evaluate` records it on the item (`result.errors`, `metadata.error`) and moves on. Pass `raise_on_error=True` to stop instead. When the `with` block raises, nothing is scored. Bad dataset columns raise `DatasetColumnError` before the run starts.
172
+
173
+ ### Custom metrics
174
+
175
+ ```python
176
+ @opengrade.metric(threshold=0.5, version=2, requires=("documents",))
177
+ def cites_source(s: opengrade.Sample) -> opengrade.Score:
178
+ hit = any(d["id"] in s.output for d in s.documents)
179
+ return opengrade.Score(1.0 if hit else 0.0, explanation="cited" if hit else "no citation")
180
+
181
+
182
+ opengrade.evaluate(dataset="gold-v1", task=answer_question, metrics=["hit_at_k", cites_source])
183
+ ```
184
+
185
+ - `Sample` fields: `input`, `output`, `expected` (dataset `expected_output`), `documents` (from `set_retrieval_documents`), `context`, `metadata`, `trace_id`, `item_id`.
186
+ - Return `opengrade.Score(value, label=, explanation=, metadata=)`, a float, a bool, `None` or `{"score": ...}`. The explanation shows per item in the UI; `metadata` (JSON-serializable, no NaN/inf) is stored with the result. A non-finite score is an ERROR.
187
+ - `label` can only fail, skip or error an item (`"FAIL"`, `"SKIPPED"`, `"ERROR"`): PASS/FAIL otherwise comes from the threshold, so `label="PASS"` does not make a score below the threshold pass.
188
+ - To change the threshold or name of an existing metric, wrap it again: `opengrade.LocalMetric(cites_source, threshold=0.8)` keeps its function, `requires` and config.
189
+ - `fn(item, output)` (two parameters) still works and receives the dataset item. `async def` metrics work too.
190
+ - `requires`: `Sample` fields that must be non-empty (`output`, `expected`, `documents`, `context`). When one is missing the item is SKIPPED, not failed.
191
+ - `version` is stored in the metric config: bump it when the code changes, so compare reports a config mismatch instead of a misleading delta.
192
+ - When the task fails on an item, that item counts as FAIL (score 0). When the metric raises, the item is ERROR. A local metric name must not match a server evaluator name (409). If the submission fails, `result.metrics` falls back to the in-process mean.
193
+
194
+ ### Params and run metrics
195
+ ```python
196
+ with opengrade.start_experiment("v2", dataset="gold-v1", params={"model": "gemma4"}) as exp:
197
+ exp.log_params(temperature=0.2) # merged into the experiment's model_config
198
+ exp.run_task(answer_question)
199
+ opengrade.log_metrics({"cost_usd": total_cost}) # experiment-level scalar, last value wins
200
+ print(exp.result.experiment_metrics)
201
+ ```
202
+
203
+ - `log_params` merges into `model_config` until an evaluation starts. After that, or when the experiment was created from an app config, the server answers 409: start a new experiment instead.
204
+ - `log_metrics` stores one final number per key (no steps). Keys use letters, digits and `_ . - /`. They appear in `result.summary()` with the delta against the baseline, and can gate a release via the release-check policy: `{"experiment_metrics": {"cost_usd": {"max": 2.0}}}`.
205
+ - `opengrade.log_params` / `opengrade.log_metrics` act on the active experiment: inside `with start_experiment(...)` and inside any item run (`exp.run(...)`, `run_task`, `evaluate()` tasks). Elsewhere they raise `RuntimeError`. With concurrency > 1, call `log_metrics`/`log_params` once outside per-item code: per-item calls overwrite each other (last write wins).
206
+ - Every `finish()` logs `opengrade.error_rate`, `opengrade.latency_p50_ms` and `opengrade.latency_p95_ms` (the task's wall-clock latency as measured by the SDK, over the items that did not fail, nearest-rank percentiles).
207
+
208
+ ## Control plane (`Client`)
209
+
210
+ HTTP client for the platform JSON API (uses `httpx2`, a core SDK dependency). Without `base_url` it uses the tracking URI. In app code prefer the shared instance, `opengrade.get_client()`, over building a `Client()` per function:
211
+
212
+ ```python
213
+ import opengrade
214
+
215
+ opengrade.set_project("demo")
216
+ client = opengrade.get_client()
217
+ datasets = client.datasets.list() # project_id defaults to the active project
218
+ exp = client.experiments.create(name="baseline", dataset_id=datasets[0].id)
219
+ ```
220
+
221
+ Project-scoped methods (`datasets.create/list/create_with_items`, `experiments.create/list`, `metrics_packs.ensure/get`, `live_runs.submit/list`, `projects.get`) take `project_id` as an optional first argument: explicit value > `set_project()` > `OPENGRADE_PROJECT_ID`, else `ValueError`.
222
+
223
+ Explicit, self-managed client:
224
+
225
+ ```python
226
+ from opengrade import Client
227
+
228
+ with Client("http://localhost:8000") as client:
229
+ project = client.projects.create(name="Demo", slug="demo")
230
+ ds = client.datasets.create_with_items(
231
+ project.id,
232
+ name="gold-v1",
233
+ task_type="rag_qa",
234
+ items=[{"input": "Q?", "expected_output": "A"}],
235
+ )
236
+ # or CSV/JSONL via multipart import:
237
+ # client.datasets.import_items(ds.id, path="gold.csv", format="csv")
238
+
239
+ exp = client.experiments.create(
240
+ project.id,
241
+ name="baseline",
242
+ dataset_id=ds.id,
243
+ model_config={"model": "gemma4:e2b"},
244
+ )
245
+ client.metrics_packs.ensure(project.id)
246
+ # after OTLP-bound runs:
247
+ client.experiments.evaluate_pack(exp.id)
248
+ summary = client.experiments.summary(exp.id)
249
+ ```
250
+
251
+ Control-plane methods return **Pydantic** models (see `opengrade.models`); use attributes or `.model_dump()` for a dict. Prefer `with Client(...)` / `client.close()` so the shared `httpx2` connection pool is released.
252
+
253
+ Namespaces: `projects`, `datasets` (`create`, `create_with_items`, `add_item`, `get`, `list`, `import_items`), `experiments` (`create`, `get`, `list_outputs`, `upsert_outputs`, `evaluate_pack`, `summary`, `compare`), `metrics_packs` (`ensure`, `get`), `metrics_sets` (`list`, `get`, `create`), `live_runs` (`submit`, `list`, `get`, `promote`, `rescore`), `app_configs` (same methods as `AppConfigClient`). Non-2xx responses raise `OpengradeAPIError` (with parsed FastAPI `detail` when present).
254
+
255
+ ### Async control plane (`AsyncClient`)
256
+
257
+ For FastAPI / async RAG apps, use `AsyncClient` so control-plane calls do not block the event loop. Prefer `opengrade.get_async_client()` and `await opengrade.aset_project(...)`. MVP surface: `health`, `projects`, `live_runs` (full), `trace_scores`. Datasets, experiments, metrics packs/sets, and app configs remain on the sync `Client`.
258
+
259
+ ```python
260
+ import opengrade
261
+
262
+
263
+ # FastAPI lifespan
264
+ async def lifespan(app):
265
+ opengrade.set_tracking_uri("http://localhost:8000")
266
+ await opengrade.aset_project("my-rag")
267
+ opengrade.init(instrument=False)
268
+ yield
269
+ await opengrade.get_async_client().aclose()
270
+
271
+
272
+ @opengrade.trace_async(name="answer")
273
+ async def answer(q: str) -> str:
274
+ text = "..."
275
+ client = opengrade.get_async_client()
276
+ await client.live_runs.submit(question=q, answer=text, documents=[])
277
+ return text
278
+ ```
279
+
280
+ Or manage the client yourself: `async with AsyncClient(...) as client: ...`. Changing `set_tracking_uri` resets both shared clients; if an event loop is already running, `aclose` the async client before changing the URI (otherwise the shared async client is dropped with a warning).
281
+
282
+ ### Live runs from tracing (production)
283
+
284
+ With `live=True`, the outermost `@opengrade.trace` call outside an experiment becomes a live interaction at OTLP ingestion; no separate `live_runs.submit` call is needed.
285
+
286
+ ```python
287
+ opengrade.init(
288
+ project_slug="acme", live=True, live_sample_rate=0.1
289
+ ) # or OPENGRADE_LIVE=1, OPENGRADE_LIVE_SAMPLE_RATE=0.1
290
+
291
+
292
+ @opengrade.trace
293
+ def answer(question: str) -> str:
294
+ docs = retrieve(question) # documents from RETRIEVER spans or set_retrieval_documents()
295
+ return llm(question, docs)
296
+ ```
297
+
298
+ - Every call is recorded; only `live_sample_rate` of them (default `1.0`) are sent to the judge. The rest show `judge_status="unsampled"` and can still be judged with `live_runs.rescore`. Sampling is deterministic on the trace id.
299
+ - Question = the only string argument (or a `question`/`query`/`input`/`prompt` kwarg); answer = the return value. Override with `opengrade.set_input(...)` / `opengrade.set_output(...)`.
300
+ - `@opengrade.trace(live=False)` excludes a function; `live=True` opts one in when `init(live=...)` is off.
301
+ - The server must run with `CONTENT_CAPTURE_ENABLED=true`, otherwise input/output are redacted and no live interaction is created.
302
+ - Idempotent per trace (`external_id="trace:<trace_id>"`).
303
+
304
+ ### Production scores and feedback
305
+
306
+ Attach scores and end-user feedback to a production trace. They are stored per trace and show up on the Live page (next to the judge verdicts), in the "Failing" filter and on the overview.
307
+
308
+ ```python
309
+ opengrade.init(live=True)
310
+
311
+
312
+ @opengrade.metric(threshold=0.5, requires=("documents",))
313
+ def cites_source(s: opengrade.Sample) -> float:
314
+ return float(any(d["id"] in s.output for d in s.documents))
315
+
316
+
317
+ @opengrade.trace(
318
+ metrics=[cites_source], metrics_sample_rate=0.2
319
+ ) # 20% of traces, scored off the request path
320
+ def answer(question: str) -> str:
321
+ docs = retrieve(question)
322
+ opengrade.set_retrieval_documents(docs)
323
+ return generate(question, docs)
324
+
325
+
326
+ @opengrade.trace
327
+ def handle(question: str) -> dict:
328
+ out = answer(question)
329
+ opengrade.log_score("answer_chars", len(out)) # current trace
330
+ return {"answer": out, "trace_id": opengrade.current_trace_id()} # send the id to your frontend
331
+
332
+
333
+ # later, from the thumbs button (trace_id is the one handle() returned)
334
+ opengrade.log_feedback(True, trace_id=trace_id, user_id="user-42", comment="great")
335
+ ```
336
+
337
+ - `log_score(name, value, *, trace_id=None, label=None, explanation=None, threshold=None, metadata=None)`: without `trace_id` it uses the current trace. PASS/FAIL follows the same rule as experiment metrics: a `FAIL` label fails; a value under `threshold` fails; otherwise, with a threshold a value at or above it passes, and without one only a value of 0 fails.
338
+ - `log_feedback(positive, *, trace_id=None, comment=None, user_id=None)`: stored as `user_feedback` (1 or 0); `positive` must be a bool (or 0/1). One value per user per trace: a user changing their mind replaces it. Without `user_id` the feedback is anonymous and all anonymous feedback on a trace shares one row (last write wins). A thumbs-down counts as a failure in the Live "Failing" filter.
339
+ - `opengrade.score_trace(metric, sample, trace_id=...)` runs one metric now, on the calling thread (only the sending is in the background), and stores the result. The trace is `trace_id`, else `sample.trace_id`, else the current trace. A raising metric queues `label="ERROR"` with the exception as explanation (returns `True` when that score is queued); missing `requires` skips with `False`.
340
+ - `@opengrade.trace(metrics=[...], metrics_sample_rate=1.0)` runs your metrics after the function returns successfully (not when it raises, not inside experiments, which score their own metrics). Needs `opengrade.init()`. Sampling is per trace and configured separately from the judge's `live_sample_rate`; both use the same hash of the trace id, so when both are set the sampled traces are nested (the lower rate's traces are a subset of the higher rate's).
341
+ - `Sample.input` is what you passed to `opengrade.set_input()` inside the call, else the only argument, else `{"args", "kwargs"}`; `Sample.documents` is the last `set_retrieval_documents()` inside the call (including nested `@opengrade.trace` helpers on the same thread); `expected` is `None`, so metrics with `requires=("expected",)` are skipped. Input, output and documents are deep-copied when the job is queued (so later mutation is invisible); samples over 256 KiB serialized are skipped with a warning. Capture uses a ContextVar: a worker thread your function starts does not inherit it, so `set_retrieval_documents` (or `record_documents`) from that thread does not feed the outer sample — call it on the traced thread before return, or pass documents into the metric yourself.
342
+ - Metrics run on a background pool (default 2 threads; `init(metrics_workers=...)` or `OPENGRADE_METRICS_WORKERS`) fed by a bounded queue; when it is full the call is not scored and a warning is logged. Async metrics time out after 60 s; a sync metric cannot be interrupted. A raising or timed-out metric stores `label="ERROR"` with the exception as explanation (never breaks the traced call). For LLM judges raise `metrics_workers` or lower `metrics_sample_rate`.
343
+ - Metrics run with instrumentation suppressed: LLM calls a judge makes and `@opengrade.trace` helpers it calls do not create production traces (spans started manually with an OpenTelemetry tracer still do).
344
+ - `trace(metrics=...)` rejects async and generator functions; use `trace_async` for coroutines.
345
+ `trace_async` also accepts `functools.partial` of a coroutine function; other async wrappers
346
+ need `inspect.markcoroutinefunction`.
347
+ - These calls never raise. `log_score`, `log_feedback` and `trace(metrics=...)` never block the caller; `score_trace()` blocks for as long as the metric takes. Invalid input (bad value, label or trace id, or metadata over 16 KB serialized) logs a warning and returns `False`; setup problems (no project id, no active trace) warn at most once a minute.
348
+ - Scores are sent in the background (batches every 2 s, 100 scores or about 512 KB). A 413 (body over the server's 1 MiB limit) splits the batch in half and resends, dropping only a single score that is still too large. A batch rejected with 400/422 is resent item by item so only the bad scores are dropped; 401/403/404 and other 4xx drop the batch at once; only network errors and 5xx are retried once after a 1 s backoff, then dropped with a warning.
349
+ - `opengrade.flush(timeout_millis)` is one total budget: spans, then pending metrics, then scores. At exit, spans, pending metrics, and scores share one ~5 s budget (span flush is capped at ~2 s of that).
350
+
351
+ ### Live runs → TestSet loop
352
+
353
+ ```python
354
+ from opengrade import Client
355
+
356
+ with Client() as client:
357
+ project_id = "..." # existing project
358
+ live = client.live_runs.submit(
359
+ project_id,
360
+ question="How many PTO days?",
361
+ answer="Unlimited.",
362
+ documents=[{"id": "pto", "text": "Full-time staff get 20 PTO days."}],
363
+ )
364
+ detail = client.live_runs.get(live.id) # or list(..., failed_only=True)
365
+ # After review, promote with reviewer-authored gold (never copy the prod answer):
366
+ ds = client.datasets.create(project_id, name="from-live", task_type="rag_qa")
367
+ client.live_runs.promote(
368
+ live.id,
369
+ dataset_id=ds.id,
370
+ expected_output="Full-time employees get 20 PTO days per year.",
371
+ expected_doc_ids=["pto"],
372
+ )
373
+ exp = client.experiments.create(project_id, name="after-promote", dataset_id=ds.id)
374
+ client.experiments.evaluate_pack(exp.id)
375
+ ```
376
+
377
+ See also [`scripts/live_run_demo.py`](../scripts/live_run_demo.py) (`--promote`).
378
+
379
+ Eval binding still uses tracing attributes (no REST call):
380
+
381
+ ```python
382
+ @opengrade.trace
383
+ def run_item(question: str) -> str:
384
+ opengrade.bind_evaluation(experiment_id=exp_id, dataset_item_id=item_id)
385
+ ...
386
+ ```
387
+
388
+ ### Registry (app configs)
389
+
390
+ `AppConfigClient` remains available for standalone use; `client.app_configs` exposes the same API:
391
+
392
+ ```python
393
+ from opengrade import AppConfigClient
394
+
395
+ registry = AppConfigClient("http://localhost:8000")
396
+ created = registry.create_app_config(
397
+ project_id,
398
+ "rag-faq",
399
+ prompt={"system": "You are helpful."},
400
+ model={"model_id": "demo"},
401
+ )
402
+ latest = registry.list_app_configs(project_id, latest=True)
403
+ registry.set_alias(project_id, "baseline", created.id)
404
+ ```
405
+
406
+ Methods: `create_app_config`, `list_app_configs` (`name`, `latest`), `set_alias`, `get_aliases`.
407
+
408
+ End-to-end prompt A/B on the People Ops RAG example (same model, two App Config prompts): `python scripts/prompt_ab_demo.py` — see `examples/hr_it_assistant/README.md`.
409
+
410
+ ## Tier-1 compatibility matrix
411
+
412
+ | Key | Target library | OpenInference package | Notes |
413
+ |---|---|---|---|
414
+ | `openai` | `openai` | `openinference-instrumentation-openai` | OpenAI API and OpenAI-compatible endpoints (e.g. Ollama `/v1`) |
415
+ | `anthropic` | `anthropic` | `openinference-instrumentation-anthropic` | Documented; manual smoke outside default CI |
416
+ | `langchain` | `langchain` / `langchain_core` | `openinference-instrumentation-langchain` | Documented; manual smoke outside default CI |
417
+ | `llama_index` | `llama_index` | `openinference-instrumentation-llama-index` | Extra name: `llama-index` |
418
+ | `bedrock` | `boto3` (Bedrock runtime) | `openinference-instrumentation-bedrock` | Documented; manual smoke outside default CI |
419
+
420
+ Install the extra for the providers you use, then `init(instrument="auto")` activates every tier-1 entry whose target library and instrumentor package are importable. Use `init(instrument=["openai", "langchain"])` to restrict keys, or `instrument=False` for decorator-only tracing.
421
+
422
+ ## Environment variables
423
+
424
+ | Variable | Purpose |
425
+ |---|---|
426
+ | `OPENGRADE_TRACKING_URI` | Platform URL for `Client()` and OTLP (default `http://localhost:8000`); `set_tracking_uri()` wins |
427
+ | `OPENGRADE_PROJECT_ID` | Project UUID header (`X-Project-Id`); used when no `set_project()` / `init()` argument |
428
+ | `OPENGRADE_PROJECT_SLUG` | Project slug header (`X-Project-Slug`); the project must already exist |
429
+ | `OPENGRADE_SERVICE_NAME` | OpenTelemetry `service.name` (default `opengrade-app`) |
430
+ | `OPENGRADE_OTLP_ENDPOINT` | Optional override of the derived OTLP URL (e.g. a separate collector) |
431
+ | `OPENGRADE_DISABLED` | `1` / `true` / `yes` / `on` turns `init()` into a no-op (decorators keep working, nothing is exported) |
432
+ | `OPENGRADE_METRICS_WORKERS` | Size of the `@opengrade.trace(metrics=...)` background pool (default `2`, range 1–64); `init(metrics_workers=...)` wins |
433
+
434
+ Project precedence for `init()`: arguments > `set_project()` > env.
435
+
436
+ ## Failure behavior
437
+
438
+ Tracing never breaks the host app:
439
+
440
+ - `init()` never raises. Without a project it logs a warning on the `opengrade` logger and stays a no-op; a broken instrumentor is skipped with a warning.
441
+ - Pending spans are flushed at interpreter exit (`atexit`, sharing the ~5 s exit budget with metrics/scores; span stage capped at ~2 s), so short scripts don't need `opengrade.flush()`.
442
+ - If the platform is unreachable, spans are dropped: one warning when export starts failing, one info when it recovers. After a failure, export pauses for 30s, so a platform that is down adds no network calls and doesn't slow down exit.
443
+
444
+ ## Content capture
445
+
446
+ The SDK may set OpenInference `input` / `output` attributes on decorated spans (`capture_input` / `capture_output`, default on). The platform API applies its own policy: with `CONTENT_CAPTURE_ENABLED=false` (default), stored trace bodies may be redacted or omitted even if the SDK sent attributes. Configure both sides explicitly for demos that need bodies in the UI.
447
+
448
+ ## Monorepo note
449
+
450
+ - SDK PyPI / import / CLI: `opengrade-ai` / `opengrade` / `opengrade`
451
+ - Server PyPI / import / CLI: `opengrade-ai-server` / `opengrade_server` / `opengrade-server` (share one venv via `opengrade-ai[ui]`)
452
+
453
+ ## Manual smoke (not CI-required)
454
+
455
+ With the platform running and a project created:
456
+
457
+ **Anthropic**
458
+
459
+ ```bash
460
+ cd sdk && uv sync --extra anthropic --extra dev
461
+ uv pip install anthropic
462
+ uv run python -c "
463
+ import opengrade
464
+ import anthropic
465
+ opengrade.init(project_slug='demo', instrument=['anthropic'])
466
+ c = anthropic.Anthropic()
467
+ @opengrade.trace
468
+ def go():
469
+ c.messages.create(model='claude-3-5-haiku-20241022', max_tokens=16, messages=[{'role':'user','content':'hi'}])
470
+ go(); opengrade.flush()
471
+ "
472
+ ```
473
+
474
+ **LangChain**
475
+
476
+ ```bash
477
+ cd sdk && uv sync --extra langchain --extra dev
478
+ # install langchain + your LLM backend as needed
479
+ uv run python -c "
480
+ import opengrade
481
+ opengrade.init(project_slug='demo', instrument=['langchain'])
482
+ # invoke your LangChain chain here
483
+ opengrade.flush()
484
+ "
485
+ ```
486
+
487
+ See platform tracing docs: [`docs/04-tracing-and-otel.md`](../docs/04-tracing-and-otel.md).