chore: import upstream snapshot with attribution
TypeScript SDK Compatibility V1.x E2E Tests / Select Node version matrix (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / TypeScript SDK Compatibility V1.x E2E Tests Node ${{matrix.node_version}} (push) Has been cancelled
TypeScript SDK E2E Tests / TypeScript SDK E2E Tests Node ${{matrix.node_version}} (push) Has been cancelled
Opik Optimizer - E2E Tests / build-opik (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / build-opik (push) Has been cancelled
Python SDK E2E Tests / Select Python version matrix (push) Has been cancelled
Python SDK E2E Tests / Python SDK E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Python SDK E2E Tests / build-opik (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / Select Python version matrix (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / Python SDK Compatibility V1.x E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / build-opik (push) Has been cancelled
TypeScript SDK E2E Tests / Select Node version matrix (push) Has been cancelled
TypeScript SDK E2E Tests / build-opik (push) Has been cancelled
Opik Optimizer - E2E Tests / Opik Optimizer E2E Tests Python ${{matrix.python_version}} (push) Has been cancelled
Opik Optimizer - E2E Tests / Opik Optimizer Integration Smoke Tests (push) Has been cancelled
🐙 Code Quality / detect (push) Has been cancelled
🐙 Code Quality / lint (${{ matrix.leg.name }}) (push) Has been cancelled
🐙 Code Quality / summary (push) Has been cancelled
TypeScript SDK Library Integration Tests / Check Secrets (push) Has been cancelled
TypeScript SDK Library Integration Tests / opik-vercel (Vercel AI SDK / eve) (push) Has been cancelled
SDK Library Integration Tests Runner / Check Secrets (push) Has been cancelled
SDK Library Integration Tests Runner / Missed OpenAI API Key Warning (push) Has been cancelled
SDK Library Integration Tests Runner / Build (push) Has been cancelled
SDK Library Integration Tests Runner / openai_tests (push) Has been cancelled
SDK Library Integration Tests Runner / langchain_tests (push) Has been cancelled
SDK Library Integration Tests Runner / langchain_legacy_tests (push) Has been cancelled
SDK Library Integration Tests Runner / llama_index_tests (push) Has been cancelled
SDK Library Integration Tests Runner / anthropic_tests (push) Has been cancelled
SDK Library Integration Tests Runner / mistral_tests (push) Has been cancelled
SDK Library Integration Tests Runner / groq_tests (push) Has been cancelled
SDK Library Integration Tests Runner / aisuite_tests (push) Has been cancelled
SDK Library Integration Tests Runner / haystack_tests (push) Has been cancelled
SDK Library Integration Tests Runner / dspy_tests (push) Has been cancelled
SDK Library Integration Tests Runner / crewai_v0_tests (push) Has been cancelled
SDK Library Integration Tests Runner / crewai_v1_tests (push) Has been cancelled
SDK Library Integration Tests Runner / genai_tests (push) Has been cancelled
SDK Library Integration Tests Runner / adk_tests (push) Has been cancelled
SDK Library Integration Tests Runner / adk_legacy_1_3_0_tests (push) Has been cancelled
SDK Library Integration Tests Runner / evaluation_metrics_tests (push) Has been cancelled
SDK Library Integration Tests Runner / bedrock_tests (push) Has been cancelled
SDK Library Integration Tests Runner / litellm_tests (push) Has been cancelled
SDK Library Integration Tests Runner / harbor_tests (push) Has been cancelled
SDK Library Integration Tests Runner / Slack Notification (push) Has been cancelled
Lint Opik Helm Chart / render-equality (push) Has been cancelled
Opik Optimizer - Unit Tests / Opik Optimizer Unit Tests Python ${{matrix.python_version}} (push) Has been cancelled
Python BE E2E Tests / Python BE E2E (push) Has been cancelled
Python Backend Tests / run-python-backend-tests (push) Has been cancelled
Python SDK Unit Tests / Python SDK Unit Tests ${{matrix.python_version}} (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
SDK E2E Libraries Integration Tests / Check Secrets (push) Has been cancelled
SDK E2E Libraries Integration Tests / Missed OpenAI API Key Warning (push) Has been cancelled
SDK E2E Libraries Integration Tests / build-opik (push) Has been cancelled
SDK E2E Libraries Integration Tests / E2E Lib Integration Python ${{matrix.python_version}} (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-gemini) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-langchain) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-openai) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-otel) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-vercel) (push) Has been cancelled
TypeScript SDK Build & Publish / build-and-publish (push) Has been cancelled
TypeScript SDK Unit Tests / Test on Node ${{ matrix.node-version }} (push) Has been cancelled
Backend Tests / discover-tests (push) Has been cancelled
Backend Tests / ${{ matrix.name }} (push) Has been cancelled
Build and Publish SDK / build-and-publish (push) Has been cancelled
Build Opik Docker Images / set-version (push) Has been cancelled
Build Opik Docker Images / build-backend (push) Has been cancelled
Build Opik Docker Images / build-sandbox-executor-python (push) Has been cancelled
Build Opik Docker Images / build-python-backend (push) Has been cancelled
Build Opik Docker Images / build-frontend (push) Has been cancelled
Build Opik Docker Images / create-git-tag (push) Has been cancelled
ClickHouse Migration Cluster Check / validate-clickhouse-migrations (push) Has been cancelled
Docs - Publish / run (push) Has been cancelled
E2E Tests - Post Merge (v2) / 🧪 E2E v2 Tests (${{ github.event.inputs.tier || 't1' }}) (push) Has been cancelled
E2E Tests - Post Merge (v2) / 📢 Slack Notification (push) Has been cancelled
Frontend Unit Tests / Test on Node 20 (push) Has been cancelled
Guardrails E2E Tests / Select Python version matrix (push) Has been cancelled
Guardrails E2E Tests / Guardrails E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Guardrails E2E Tests / 📢 Slack Notification (push) Has been cancelled
Guardrails Backend Unit Tests / Guardrails Backend Unit Tests (push) Has been cancelled
Guardrails Backend Unit Tests / 📢 Slack Notification (push) Has been cancelled
Lint Opik Helm Chart / lint-helm-chart (Helm v3.21.0) (push) Has been cancelled
Lint Opik Helm Chart / lint-helm-chart (Helm v4.2.0) (push) Has been cancelled
Lint Opik Helm Chart / unittest-helm-chart (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:25:44 +08:00
commit 5a558eb09e
11579 changed files with 1795921 additions and 0 deletions
+62
View File
@@ -0,0 +1,62 @@
# Exclude a variety of commonly ignored directories.
exclude = [
".bzr",
".direnv",
".eggs",
".git",
".git-rewrite",
".hg",
".ipynb_checkpoints",
".mypy_cache",
".nox",
".pants.d",
".pyenv",
".pytest_cache",
".pytype",
".ruff_cache",
".svn",
".tox",
".venv",
".vscode",
"__pypackages__",
"_build",
"buck-out",
"build",
"dist",
"node_modules",
"site-packages",
"venv",
"src/opik/rest_api",
]
# Same as Black.
line-length = 88
indent-width = 4
# Assume Python 3.10
target-version = "py310"
[lint]
# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default.
select = ["E4", "E7", "E9", "F"]
ignore = []
# Allow fix for all enabled rules (when `--fix`) is provided.
fixable = ["ALL"]
unfixable = []
# Allow unused variables when underscore-prefixed.
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
[format]
# Like Black, use double quotes for strings.
quote-style = "double"
# Like Black, indent with spaces, rather than tabs.
indent-style = "space"
# Like Black, respect magic trailing commas.
skip-magic-trailing-comma = false
# Like Black, automatically detect the appropriate line ending.
line-ending = "auto"
+77
View File
@@ -0,0 +1,77 @@
# Repository Guidelines
## Scope & Inheritance
- This file contains Python SDK specifics only.
- Follow `../../AGENTS.md` for shared monorepo workflow, PR, and security policy.
## Project Structure & Module Organization
This SDK lives under `sdks/python`.
- `src/opik/`: Python package source.
- `tests/`: test suite, organized into `unit/`, `integration/`, `e2e/`, `e2e_library_integration/`, and `e2e_smoke/`.
- `examples/`: runnable integration examples and recipes.
- `design/` and `outputs/`: design assets and generated artifacts.
- `README.md`: SDK overview and contributor entry points.
## Build, Test, and Development Commands
See also `../../AGENTS.md#build-test-and-development-commands` for full monorepo commands.
Run commands from `sdks/python` unless noted.
- `pip install -r tests/test_requirements.txt && pytest tests/unit tests/integration tests/e2e`: install test dependencies and run standard tests.
- `pytest tests/e2e_library_integration tests/e2e_smoke`: run higher-cost integration coverage.
- `cd "$(git rev-parse --show-toplevel)" && make precommit`: run formatting, linting, and mypy hooks on changed files (vs origin/main) via the root pre-commit config.
- `opik configure --use_local` (or `opik configure`): local SDK configuration for local/dev environments.
## Coding Style & Naming Conventions
- Python target matches the modules supported versions in `pyproject.toml` (currently 3.10+) with 4-space indentation and line length 88.
- Primary style tooling: `ruff` and `ruff format` (configured in `.ruff.toml`) plus `mypy` (via pre-commit).
- Prefer explicit names, avoid abbreviations; avoid `utils.py`/`helpers.py` style catch-alls.
- Prefer module-style imports over single-name imports in new code.
- Keep names private with `_` prefix only when not used outside the module.
- Keep comments focused on intent (“why”), not mechanics (“what”).
## Testing Guidelines
- Prefer unit tests (`tests/unit`) for behavior changes.
- Add integration tests when touching backend or integration behavior, and e2e tests for cross-system flows.
- Use existing fixture patterns in `tests/unit` and `tests/library_integration`.
- Run focused suites before PR submission; avoid relying only on broad e2e runs when unit tests suffice.
- File naming: `test_*.py` under `tests/<category>/`.
### E2E test isolation contract (`tests/e2e/`)
The e2e suite runs under `pytest-xdist` with `--dist=loadfile`: each test file is dispatched to one worker, and multiple files run in parallel against a shared backend. Resource names must therefore not collide across files.
- **Backend project name** for a test module comes from `generate_project_name("e2e", __name__)` (helper in `tests/testlib/project_naming.py`, re-exported from `tests.testlib`). Files that need to reference the project (verifier fallback, `search_traces`, etc.) declare at module top:
```python
from ..testlib import generate_project_name
PROJECT_NAME = generate_project_name("e2e", __name__)
```
Reference `PROJECT_NAME` directly in test bodies — do not introduce a `project_name = PROJECT_NAME` indirection. The autouse `configure_e2e_tests_env` fixture reads `PROJECT_NAME` from each test module and patches `OPIK_PROJECT_NAME`, so the constant is the single source of truth. Files that don't reference the project name in Python don't need to declare anything; the fixture falls back to deriving a name from the module.
- **Alternative projects** — used to exercise the `project_name=` override path — must not embed `generate_project_name(...)` as a `@pytest.mark.parametrize` decorator value. Every worker collects every parametrize id, and xdist's collection-consistency check fails when ids differ across workers; `generate_project_name` returns a different value per process. Parametrize on a boolean and compute the project name inside the test body:
```python
@pytest.mark.parametrize("override_project_name", [True, False])
def test_xxx(opik_client, override_project_name):
project_name = (
generate_project_name("e2e", "anonymization", "override")
if override_project_name else None
)
...
```
Each CI job has its own backend stack, and `--dist=loadfile` keeps each file on a single worker, so different workers computing different names is not a collision risk in practice.
- **Per-test resources** — datasets, experiments, prompts, temporary projects — already use unique names via the `dataset_name`, `experiment_name`, `prompt_name`, `temporary_project_name` fixtures. Use them; do not invent your own per-test name.
- **No raw `random_chars()` calls for project names.** Reach for it directly only when you need a non-project resource name and there is no fixture for it.
- **No bare hardcoded literals for project / dataset / experiment / prompt / suite / annotation-queue / optimization names anywhere under `tests/e2e/**`.** Strings derived from a unique-per-test fixture (e.g. `f"test_optimization_{dataset_name}"`) are fine — `dataset_name` already injects a random suffix.
- **`configure_e2e_tests_env` is autouse and module-scoped.** Do not narrow it; teardown ordering under xdist will surface narrower scopes as flake.
- **xdist + classes**: with `--dist=loadfile` test classes are *not* split across workers — every test in a file (including those inside `class Test…`) runs on the same worker. Module-level constants and module-scoped fixtures span both module-level and class-level tests in that file. If you switch a file to `--dist=loadscope`, revisit the scope contract.
If you find a hardcoded resource name during code review, treat it as a defect on the same severity as a missing teardown.
## Agent Contribution Workflow
- This module is part of the Opik monorepo; follow the shared workflow in `../../AGENTS.md#agent-contribution-workflow`.
- Run relevant formatter and test commands in this file for Python SDK changes before requesting review.
## Commit & Pull Request Guidelines
- Follow shared commit/PR policy in `../../AGENTS.md`.
- Python SDK-specific convention: use SDK-prefixed titles (for example `[OPIK-####] [SDK] ...`) when applicable.
## Security & Configuration Tips
- Follow shared security policy in `../../AGENTS.md`.
- Python SDK-specific rule: configure credentials via `opik configure`/environment variables, never hardcode them.
+203
View File
@@ -0,0 +1,203 @@
Copyright (c) Comet ML, Inc
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 2024 Comet ML, Inc
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.
+188
View File
@@ -0,0 +1,188 @@
# Opik Python SDK
[![PyPI version](https://img.shields.io/pypi/v/opik.svg)](https://pypi.org/project/opik/)
[![Python versions](https://img.shields.io/pypi/pyversions/opik.svg)](https://pypi.org/project/opik/)
[![Downloads](https://static.pepy.tech/badge/opik)](https://pepy.tech/project/opik)
[![License](https://img.shields.io/github/license/comet-ml/opik)](https://github.com/comet-ml/opik/blob/main/LICENSE)
The Opik Python SDK allows you to integrate your Python applications with the Opik platform, enabling comprehensive tracing, evaluation, and monitoring of your LLM systems. Opik helps you build, evaluate, and optimize LLM systems that run better, faster, and cheaper.
Opik is an open-source LLM evaluation platform by [Comet](https://www.comet.com?from=llm&utm_source=opik&utm_medium=github&utm_content=python_sdk_readme&utm_campaign=opik). For more information about the broader Opik ecosystem, visit our main [GitHub repository](https://github.com/comet-ml/opik), [Website](https://www.comet.com/site/products/opik/), or [Documentation](https://www.comet.com/docs/opik/).
## Quickstart
Get started quickly with Opik using our interactive notebook:
<a href="https://colab.research.google.com/github/comet-ml/opik/blob/master/apps/opik-documentation/documentation/docs/cookbook/opik_quickstart.ipynb">
<img src="https://colab.research.google.com/assets/colab-badge.svg" alt="Open Quickstart In Colab"/>
</a>
## Installation
Install the `opik` package using pip or uv:
```bash
# using pip
pip install opik
# using uv (faster)
uv pip install opik
```
## Configuration
Configure the Python SDK by running the `opik configure` command. This will prompt you for your Opik server address (for self-hosted instances) or your API key and workspace (for Comet.com):
```bash
opik configure
```
You can also configure the SDK programmatically in your Python code:
```python
import opik
# For Comet.com Cloud
opik.configure(
api_key="YOUR_API_KEY",
workspace="YOUR_WORKSPACE", # Usually found in your Comet URL: https://www.comet.com/YOUR_WORKSPACE/...
project_name="optional-project-name" # Optional: set a default project for traces
)
# For self-hosted Opik instances
# opik.configure(use_local=True, project_name="optional-project-name")
```
Refer to the [Python SDK documentation](https://www.comet.com/docs/opik/python-sdk-reference/) for more configuration options.
### Dynamic Tracing Control
Control tracing behavior at runtime without code changes:
```python
import opik
# Disable tracing globally
opik.set_tracing_active(False)
# Check current state
print(opik.is_tracing_active()) # False
# Re-enable tracing
opik.set_tracing_active(True)
# Reset to configuration default
opik.reset_tracing_to_config_default()
```
This is useful for:
- Performance optimization in high-throughput systems
- Conditional tracing based on user type or request parameters
- Debugging and troubleshooting without redeployment
- Implementing sampling strategies
- Calls already in progress when you disable tracing still finish logging.
See `examples/dynamic_tracing_cookbook.py` for comprehensive usage patterns.
## Basic Usage: Tracing
The easiest way to log traces is to use the `@opik.track` decorator:
```python
import opik
# Ensure Opik is configured (see Configuration section above)
# opik.configure(...)
@opik.track
def my_llm_function(user_question: str) -> str:
# Your LLM call or business logic here
# For example:
# response = openai.ChatCompletion.create(...)
response = f"Echoing: {user_question}"
# You can add metadata to your trace
opik.set_tags(["example", "basic-usage"])
opik.log_metadata({"question_length": len(user_question)})
return response
my_llm_function("Hello, Opik!")
```
Traces will appear in your configured Opik project. Opik also offers many direct [integrations](https://www.comet.com/docs/opik/integrations/overview/) for popular LLM frameworks.
## CLI Commands
Opik provides powerful CLI commands for exporting and importing data between projects:
- **Export**: Export traces, datasets, and prompts from projects to local JSON files
- **Import**: Import data from local files into projects
- **Migration**: Move data between projects or environments
- **Backup**: Create local backups of your project data
For detailed information about the CLI export/import functionality, see [Import/Export Commands](../../apps/opik-documentation/documentation/fern/docs/tracing/import_export_commands.mdx).
## Development & Contribution Guidelines
For a more general contribution guide (backend + frontend + SDK) see our root [Contribution guide](../../CONTRIBUTING.md).
# Coding guidelines
This guide is still in progress, however, it already contains useful information that you should know before submitting your PR.
## General
We care a lot about the code maintainability. Well-organized logic which is easy to extend, re-factor and, most importantly - **read**, is what we are striving for.
1. Follow [SOLID](https://realpython.com/solid-principles-python/) principles. Pay special attention to the "Single Responsibility" one.
2. Avoid large modules, large classes, and large functions. Separate the code properly and describe this separation with names, not with comments. (See [1])
3. If the name is not used outside of the class/module - it should be `_protected`.
4. Don't violate the access rules! We know that Python allows you to access _protected/__private variables, but in Opik we are quite strict about not abusing that, whether it's an internal code or a test (don't forget about [3]!).
5. Use comments only for something non-trivial that is hard to describe in any other way. Apart from these cases, comments should be used to answer the question "Why?" not "What?".
## Imports
1. Import module - not name.
Instead of this:
```python
from threading import Thread # bad!
thread = Thread()
```
do this:
```python
import threading # good!
thread = threading.Thread
```
2. If the import statement is too big, you can do the following
```python
from opik.rest_api.core import error as rest_api_error # ok!
```
3. If you are working in the namespace, you likely don't need to keep most of the parent namespaces
```python
# inside opik.api_objects.dataset
from . import dataset_item # ok!
```
4. Of course, there might be exceptions from this rule, for example, some common types can be imported as is.
```python
from typing import Dict, List # ok!
from opik.types import FeedbackScoreDict # ok!
```
## Naming
1. Avoid abbreviations. In the vast majority of cases, it is not a problem to use variable names. People spend more time understanding what "fs" means than reading the word "files" or "file_system".
```python
for d in dataset_items: # bad!
for item in dataset_items: # ok!
...
for dataset_item in dataset_items # ok!
...
```
2. Avoid creating modules like `utils.py`, `helpers.py`, `misc.py` etc. Especially in the big namespaces. They can quickly become dumps where people put everything that they haven't been able to create a better place for in 10 seconds after they started thinking about it. You can create those files though, but they should be localized in their namespaces designed for some specific features. In vast majority of cases there are better module names.
## Testing
We highly encourage writing tests and we develop a lot of features in a test-driven way.
1. Test public API, don't violate privacy.
2. If you are an external contributor - make sure that the unit tests and e2e tests are green (they can be executed anywhere because they don't require any API keys or permissions). For internal Opik developers everything should be green in the CI.
3. If you have `if-statements` in your code or some non-trivial boiler-plate code - it's probably a reason to think about add some unit tests for that. The more complex your code, the higher chance you'll be asked to provide unit tests for it.
4. If you are introducing a new feature that includes communication with the backend - it's better to add some e2e tests for that (at least the happy flow one).
5. Avoid testing with e2e tests something that can be tested with unit tests. E2E tests are time-consuming.
6. If you are introducing a change in one of the integrations (or a new integration), make sure the integration tests are working. They usually require API keys configured for the services the integration works with. When the external contributor opens a PR, their tests will not use our Github secrets so consider providing your repo with an API key required for the integration. In that case, we will see that the tests are green.
7. We are using `fake_backend` fixture together with a special Opik assertions DSL(domain-specific language) for a lot of unit tests and library integration tests. We encourage you to use it as well! There is plenty of examples, you can take a look at `tests/unit/decorator/test_tracker_outputs.py` or `tests/library_integration/openai/test_openai.py`. It provides a pretty simple API for specifying the traces content you expect your feature to log.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+595
View File
@@ -0,0 +1,595 @@
# Opik Python SDK: Integrations Architecture
## Table of Contents
- [Overview](#overview)
- [Integration Patterns](#integration-patterns)
- [Method Patching Integrations](#method-patching-integrations)
- [Callback Integrations](#callback-integrations)
- [Hybrid Integrations](#hybrid-integrations)
- [Streaming Strategies](#streaming-strategies)
- [Token Usage and Cost Tracking](#token-usage-and-cost-tracking)
## Overview
The SDK provides automatic tracking for 12+ LLM frameworks through three architectural patterns. Integrations are designed to be lightweight, extensible, and framework-native.
### Integration Catalog
| Integration | Pattern | Location | Key Features |
|-------------|---------|----------|--------------|
| **OpenAI** | Method Patching | `integrations/openai/` | Multiple APIs, streaming, function calling |
| **Anthropic** | Method Patching | `integrations/anthropic/` | Messages API, delta accumulation |
| **Bedrock** | Method Patching | `integrations/bedrock/` | Multi-format aggregators, extensible |
| **Google GenAI** | Method Patching | `integrations/genai/` | Multi-modal support |
| **AISuite** | Method Patching | `integrations/aisuite/` | Unified interface |
| **LangChain** | Callback | `integrations/langchain/` | BaseTracer, provider extractors, external context support |
| **LlamaIndex** | Callback | `integrations/llama_index/` | Event parsing, dedicated client |
| **DSPy** | Callback | `integrations/dspy/` | Isolated context, graph visualization |
| **Haystack** | Callback | `integrations/haystack/` | Component-based |
| **ADK** | Hybrid | `integrations/adk/` | OpenTelemetry interception + callbacks |
| **CrewAI** | Hybrid | `integrations/crewai/` | Method wrapping + LiteLLM delegation |
## Integration Patterns
### Pattern Selection
```
Library Architecture Analysis:
Does library provide callbacks/hooks?
├─► Yes ─► Callbacks reliable and in-context?
│ │
│ ├─► Yes ─► Pure Callback
│ │ (LangChain, LlamaIndex, DSPy, Haystack)
│ │
│ └─► No ─► Hybrid (Callback + Patching)
│ (ADK, CrewAI)
└─► No ─► Method Patching
(OpenAI, Anthropic, Bedrock, GenAI, AISuite)
```
### Callback Reliability Issues
**Why callbacks alone may be insufficient**:
1. **Completion guarantee**: Some frameworks skip END callbacks on exceptions
2. **Context isolation**: Callbacks may execute in different thread/context than original call
3. **Timing**: Callbacks may fire with delays, complicating context management
**Solution**: Add patching/integration for OpenTelemetry interception (ADK) or external dependency tracking (CrewAI).
## Method Patching Integrations
### Architecture
Method patching wraps client methods to intercept calls:
```
track_library(client) → Wraps methods → client.method() intercepted
BaseTrackDecorator
_start_span_inputs_preprocessor
(extract input, create span)
Call original method
_streams_handler
(check if output is stream)
┌────────┴────────┐
│ │
Stream? Not stream
│ │
Patch stream │
Defer finalization │
Return patched │
│ │
└────────┬────────┘
_end_span_inputs_preprocessor
(extract output, usage, finalize span)
(called immediately for non-streaming,
or in finally block for streaming)
```
**All method patching integrations are idempotent**: Use `opik_tracked` marker to prevent double-wrapping.
### OpenAI Integration
**Files**:
- `opik_tracker.py` - Main entry point, wraps client methods
- `openai_chat_completions_decorator.py` - Chat completions decorator
- `openai_responses_decorator.py` - Responses API decorator
- `stream_patchers.py` - Stream iteration patching
- `chat_completion_chunks_aggregator.py` - Chunk aggregation
- `response_events_aggregator.py` - Response events aggregation
**Wrapped Methods**:
- `chat.completions.create()` - Standard chat API
- `beta.chat.completions.parse()` - Structured outputs
- `responses.create()` - Responses API
**Streaming Support**: Handles `openai.Stream`, `openai.AsyncStream`, and `ChatCompletionStreamManager`.
### Anthropic Integration
**Files**:
- `opik_tracker.py` - Main entry point
- `messages_create_decorator.py` - Messages decorator
- `stream_patchers.py` - Stream/context manager patching
**Wrapped Methods**:
- `messages.create()` - Both standard and streaming
- `messages.stream()` - Context manager pattern
**Key Implementation Detail**: **Delta Accumulation**
Anthropic streams delta events (not complete chunks) that must be accumulated. Event accumulator builds complete message by merging deltas progressively.
**Location**: `stream_patchers.py` - See accumulation logic
### Bedrock Integration
**Files**:
- `opik_tracker.py` - Main entry point
- `converse/converse_decorator.py` - Converse API
- `invoke_model/invoke_model_decorator.py` - Legacy InvokeModel API
- `invoke_model/chunks_aggregator/` - Extensible aggregator system
**Wrapped Methods**:
1. `client.converse()` - Unified Converse API
2. `client.invoke_model()` - Legacy API (multiple formats)
3. `client.invoke_agent()` - Agent invocations
**Key Implementation Detail**: **Extensible Multi-Format Aggregator**
**Problem**: Bedrock supports multiple model formats (Claude, Nova, Llama, Mistral) with different streaming structures.
**Solution**: Registry pattern with pluggable aggregators.
**Architecture** (`invoke_model/chunks_aggregator/`):
- `base.py` - `ChunkAggregator` protocol
- `format_detector.py` - Detection registry + aggregator registry
- `claude.py`, `nova.py`, `llama.py`, `mistral.py` - Format-specific aggregators
- `api.py` - Public interface: `detect_format()` + `aggregate_chunks_to_dataclass()`
**Extensibility**: Add new format by creating module + registering in `format_detector.py`. Zero changes to existing code.
**Benefits**: Open/Closed Principle, isolated testing, clear separation of concerns.
**Documentation**: See `EXTENDING.md` and `README.md` in `chunks_aggregator/` directory.
### Google GenAI Integration
**Files**:
- `opik_tracker.py` - Main entry point
- `generate_content_decorator.py` - Content generation decorator
- `stream_wrappers.py` - Stream handling
- `generations_aggregators.py` - Chunk aggregation
**Features**: Multi-modal support (text, images), streaming responses.
### AISuite Integration
**Files**:
- `opik_tracker.py` - Main entry point
- `aisuite_decorator.py` - Decorator implementation
**Pattern**: Similar to OpenAI (unified interface across providers).
## Callback Integrations
### Architecture
Callback integrations implement framework's callback interface:
```
Framework execution → Fires events → Callback methods
on_start() - Create span/trace
on_end() - Update and send
on_error() - Capture error, finalize
```
### LangChain Integration
**Files**:
- `opik_tracer.py` - Implements `BaseTracer`
- `langgraph_tracer_injector.py` - Graph configuration injection for LangGraph
- `langgraph_async_context_bridge.py` - Context propagation for async LangGraph nodes
- `provider_usage_extractors/` - Provider-specific usage extraction
- `helpers.py` - Utility functions
- `base_llm_patcher.py` - Adds `base_url` to LLM dict (for provider ID)
**Pattern**: Pure callback (extends `langchain_core.tracers.BaseTracer`)
**Key Feature**: **Supports parent-child relations with external Opik spans/traces**
When used within `@track` decorated functions or existing Opik trace context:
- Detects existing trace in `context_storage`
- Creates LangChain spans as children of current Opik span
- Maintains proper hierarchy between Opik and LangChain operations
Example:
```python
@opik.track # Opik trace + span
def my_function():
chain.invoke(..., callbacks=[OpikTracer()]) # LangChain spans as children
```
**State Management**:
- `_span_data_map: Dict[UUID, SpanData]` - Maps LangChain run_id to Opik span
- `_created_traces_data_map: Dict[UUID, TraceData]` - Maps run_id to trace
- `_externally_created_traces_ids: Set[str]` - Tracks external traces
**Callback Methods** (implements full `BaseTracer` interface):
**Chain callbacks**:
- `_on_chain_start(run)` → Check for existing trace, create span as child if exists
- `_on_chain_end(run)` → Finalize span, send to backend
- `_on_chain_error(run)` → Capture error info, finalize span
**LLM callbacks**:
- `on_chat_model_start(...)` → Special handling for chat models
- `_on_chat_model_start(run)` → Internal processing
- `_on_llm_start(run)` → Create LLM span (type="llm"), extract provider
- `_on_llm_end(run)` → Extract usage via provider extractors, send span
- `_on_llm_error(run)` → Capture error, finalize span
**Tool callbacks**:
- `_on_tool_start(run)` → Create tool span (type="tool")
- `_on_tool_end(run)` → Finalize tool span
- `_on_tool_error(run)` → Capture error, finalize span
Error callbacks ensure spans finalized even when LangChain operations fail.
**Key Implementation Detail**: **Provider-Specific Usage Extractors**
**Location**: `provider_usage_extractors/`
**Challenge**: Each LangChain provider stores usage in different locations/formats within the `Run` object.
**Solution**: Registry pattern with provider-specific extractors.
Extractors:
- `OpenAIUsageExtractor` - Extracts from `run.outputs.llm_output.token_usage`
- `AnthropicUsageExtractor` - Handles Anthropic format
- `BedrockUsageExtractor` - Handles Bedrock format
- `GoogleUsageExtractor` - Handles Google format
- See `usage_extractor.py` for full registry
Each extractor knows where to find usage in that provider's Run structure.
**LangGraph Support**:
The integration provides enhanced support for LangGraph through:
1. **`track_langgraph()` Function**: High-level wrapper that injects `OpikTracer` into the graph's default configuration, eliminating the need to pass `config={"callbacks": [opik_tracer]}` on every invocation.
2. **Automatic Graph Visualization**: Extracts and stores Mermaid graph structure in trace metadata via `OpikTracer.set_graph()` method.
3. **Async Context Bridge**: `extract_current_langgraph_span_data()` helper for propagating trace context to `@track`-decorated functions in async LangGraph nodes.
**Usage Pattern**:
```python
from opik.integrations.langchain import OpikTracer, track_langgraph
from langgraph.graph import StateGraph, START, END
# Build and compile graph
builder = StateGraph(State)
builder.add_node("my_node", my_node_function)
builder.add_edge(START, "my_node")
builder.add_edge("my_node", END)
app = builder.compile()
# Track once
opik_tracer = OpikTracer(tags=["production"])
app = track_langgraph(app, opik_tracer)
# All invocations automatically tracked
result = app.invoke({"message": "Hello"})
```
**Implementation Details**:
- `langgraph_tracer_injector.py` - Injects `OpikTracer` into graph's default config
- `langgraph_async_context_bridge.py` - Extracts span data from LangGraph config for async context propagation
- `OpikTracer.set_graph()` - Stores graph visualization in `_trace_default_metadata["_opik_graph_definition"]`
### LlamaIndex Integration
**Files**:
- `callback.py` - Implements `BaseCallbackHandler`
- `event_parsing_utils.py` - Parses LlamaIndex event payloads
**Event Handling**:
- `on_event_start(event_type, payload, event_id, parent_id)` → Parse payload, create span
- `on_event_end(event_type, payload, event_id)` → Parse output/usage, send span
**Event Parser** (`event_parsing_utils.py`): Extracts data from payloads based on `event_type` (EMBEDDING, QUERY, LLM, etc.).
### DSPy Integration
**Files**:
- `callback.py` - Implements `dspy.utils.callback.BaseCallback`
- `graph.py` - Mermaid graph builder for DSPy programs
**Callbacks**:
- `on_module_start/end()` - DSPy module execution
- `on_lm_start/end()` - LM calls (extracts provider/model from "provider/model" format)
- `on_tool_start/end()` - Tool executions
**Key Implementation Detail**: **Global Context Storage with Safe Operations**
Uses global `OpikContextStorage` instance, enabling `opik.opik_context` API access to spans/traces created by DSPy callbacks. This allows users to:
- Access current span/trace data via `opik_context.get_current_span_data()` / `opik_context.get_current_trace_data()`
- Update spans/traces via `opik_context.update_current_span()` / `opik_context.update_current_trace()`
**Context Safety**: Uses `ensure_id` parameter for all context pop operations (`pop_span_data(ensure_id=...)`, `pop_trace_data(ensure_id=...)`) to prevent context corruption in concurrent scenarios or when DSPy callbacks coexist with `@track` decorated functions.
**Graph Visualization**: Builds Mermaid diagram of DSPy program structure (`graph.py`).
### Haystack Integration
**Files**:
- `opik_connector.py` - Component added to pipeline
- `opik_tracer.py` - Tracer for pipeline execution
- `converters.py` - Convert Haystack objects to Opik format
**Pattern**: Component-based (added to pipeline, observes without modifying data flow).
## Hybrid Integrations
### ADK Integration
**Files**:
- `opik_tracer.py` - Agent callbacks
- `patchers/adk_otel_tracer/opik_adk_otel_tracer.py` - OpenTelemetry tracer
- `recursive_callback_injector.py` - Recursive callback injection
- `graph/mermaid_graph_builder.py` - Agent graph visualization
- `patchers/patchers.py` - Global patches
**Why Hybrid**: ADK uses OpenTelemetry for internal tracing + provides agent callbacks.
**Dual Approach**:
1. **OpenTelemetry Patching** (`patchers/adk_otel_tracer/opik_adk_otel_tracer.py`):
- Intercepts `start_span()` calls from ADK
- Creates Opik spans instead
- Returns `INVALID_SPAN` (no-op for OpenTelemetry)
- Skips internal ADK spans via `_ADK_INTERNAL_SPAN_NAME_SKIP_LIST`
2. **Agent Callbacks** (`opik_tracer.py`):
- `before/after_agent_callback`
- `before/after_model_callback`
- `before/after_tool_callback`
- Recursively injected into agent tree (`recursive_callback_injector.py`)
**Key Implementation Details**:
1. **OpenTelemetry Interception**: Instead of dual tracing (OTel + Opik), intercepts OTel tracer to create only Opik spans. Single tracing backend, no OpenTelemetry overhead. Callbacks is used only to update spans and traces, but it's OTel tracer that is responsible
for creating them and working with context (it's done to benefit from reliability of OTel context manager)
2. **Graph Visualization** (`graph/mermaid_graph_builder.py`): Generates Mermaid diagram of agent structure including:
- Agent types (Sequential, Loop, Parallel, LLM)
- Tools and their connections
- Subagent relationships
- Stored in trace metadata `_opik_graph_definition`
### CrewAI Integration
**Files**:
- `opik_tracker.py` - Main tracking setup
- `crewai_decorator.py` - Decorator for CrewAI methods
- `flow_patchers.py` - Flow class patching
**Why Hybrid**: CrewAI methods wrapped + LiteLLM used for LLM tracking + direct provider client patching for v1.0.0+.
**Approach**:
1. **Method Wrapping**: Wrap `Crew.kickoff`, `Agent.execute_task`, `Task.execute_sync`
2. **LiteLLM Delegation**: Enable `litellm.track_litellm()` (CrewAI uses LiteLLM internally for v0.x)
3. **Flow Patching**: Patch `Flow.__init__` to auto-wrap dynamically registered methods (v1.0.0+ only)
4. **Provider Client Patching**: For v1.0.0+, directly patch OpenAI, Anthropic, Gemini, and Bedrock clients when `crew` argument is provided
**Key Implementation Details**:
1. **LiteLLM Delegation**: Reuses existing LiteLLM integration instead of duplicating LLM tracking logic.
2. **Flow Patching** (`flow_patchers.py`): Patches constructor to wrap methods registered via `@start`, `@listen` decorators. Gracefully handles missing `Flow` class (not available in CrewAI < v1.0.0).
3. **Graceful Degradation**: Handles missing provider libraries gracefully:
- If a provider library (e.g., `crewai.llms.providers.openai.completion`) is not installed, logs debug message and continues
- If tracking a specific provider client fails, logs warning and continues with other providers
- Ensures integration doesn't fail if some optional dependencies are missing
**Usage**:
```python
# For CrewAI v0.x (LiteLLM-based)
track_crewai(project_name="my-project")
# For CrewAI v1.0.0+ (direct provider clients)
crew = Crew(agents=[...], tasks=[...])
track_crewai(project_name="my-project", crew=crew) # crew argument enables LLM client tracking
```
## Streaming Strategies
### Streaming Challenges
1. **Deferred finalization**: Can't finalize span until stream consumed
2. **User-controlled consumption**: User determines when/if stream is fully consumed
3. **Chunk accumulation**: Need complete response for logging
4. **Error handling**: Exceptions during iteration
5. **Context cleanup**: Must finalize even if stream abandoned
### Strategy 1: Monkey-Patch Class Iterator
**Used by**: OpenAI (`openai.Stream`), Anthropic (`anthropic.Stream`)
**Files**: `stream_patchers.py` in each integration
**Approach**:
1. Save original `__iter__` from class
2. Create wrapper that accumulates chunks
3. Replace class method: `Stream.__iter__ = wrapper`
4. Mark instance: `stream.opik_tracked_instance = True`
5. Attach span/trace data to instance
6. Wrapper checks marker before processing
**Key Pattern - Context Pop Before Streaming**:
Before returning stream, pop span/trace from context:
```python
def _streams_handler(self, output, ...):
if is_stream(output):
# Pop BEFORE returning (stream consumed later)
span_to_end, trace_to_end = base_track_decorator.pop_end_candidates()
return patch_stream(output, span_to_end, trace_to_end, ...)
```
**Why**: Stream consumption happens after decorator returns. Popping prevents nested calls from seeing stale context.
**Key Pattern - Finalization Guarantee**:
All stream wrappers use `finally`:
```python
def wrapper(self):
try:
accumulated = []
for item in original(self):
accumulated.append(item)
yield item
finally:
# ALWAYS runs - even if stream not fully consumed
finalize_span(aggregator(accumulated), ...)
```
**Why**: User might break early or exception occurs. Span must finalize.
### Strategy 2: Context Manager Patching
**Used by**: Anthropic (`MessageStreamManager`)
**Approach**:
- Patch `__enter__` and `__exit__` of stream manager
- Accumulate during iteration (between enter/exit)
- Finalize in `__exit__`
**Files**: `stream_patchers.py`
Suitable for stream managers that use `with` statement pattern.
### Strategy 3: Generator Wrapper
**Used by**: Some Bedrock/GenAI cases
**Location**: `opik/decorator/generator_wrappers.py`
**Approach**: Wrap generator without modifying library classes. Returns custom proxy that finalizes in `__del__` or explicit close.
## Token Usage and Cost Tracking
### OpikUsage - Standardized Format
**Location**: `opik/llm_usage/opik_usage.py`
All providers map to standardized format:
```python
class OpikUsage(pydantic.BaseModel):
completion_tokens: Optional[int]
prompt_tokens: Optional[int]
total_tokens: Optional[int]
provider_usage: Optional[BaseOriginalProviderUsage] # Original preserved
```
### Usage Factory - Registry Pattern
**Location**: `opik/llm_usage/opik_usage_factory.py`
Registry with builder functions per provider:
```python
_PROVIDER_TO_OPIK_USAGE_BUILDERS: Dict[Provider, List[Callable]] = {
LLMProvider.OPENAI: [
OpikUsage.from_openai_completions_dict,
OpikUsage.from_openai_responses_dict, # Multiple formats supported
],
LLMProvider.ANTHROPIC: [OpikUsage.from_anthropic_dict],
LLMProvider.BEDROCK: [OpikUsage.from_bedrock_dict],
# ...
}
```
**Process**:
1. Integration extracts usage dict from response
2. Calls `build_opik_usage(provider, usage_dict)`
3. Factory tries each builder (supports multiple formats per provider)
4. Returns standardized `OpikUsage`
**Extensibility**: Add new provider by:
1. Create `MyProviderUsage` class
2. Add `from_myprovider_dict()` to `OpikUsage`
3. Register in factory
### Provider Enum
**Location**: `opik/types.py`
Supported providers for cost tracking:
- `OPENAI`, `ANTHROPIC`, `BEDROCK`
- `GOOGLE_VERTEXAI`, `GOOGLE_AI`
- `COHERE`, `GROQ`
- See `types.py` for complete list
### Cost Calculation
**SDK Responsibility**: Provide data
- `model`: Model name (e.g., "gpt-4")
- `provider`: Provider enum
- `usage`: Token counts (OpikUsage)
- `total_cost`: Optional override
**Backend Responsibility**: Calculate cost
- Pricing tables (model → price per token)
- Region-specific pricing (Bedrock)
- Token usage multiplication
**Note**: Integrations do **not** calculate cost - only provide data for backend.
## Summary
**Integration Patterns**:
- **Method Patching**: OpenAI, Anthropic, Bedrock, GenAI, AISuite
- **Callback**: LangChain, LlamaIndex, DSPy, Haystack
- **Hybrid**: ADK (callbacks + OTel), CrewAI (methods + LiteLLM)
**Streaming Strategies**:
- Class method patching (OpenAI, Anthropic Stream)
- Context manager patching (Anthropic MessageStreamManager)
- Generator wrapper (Bedrock, GenAI)
**Key Patterns**:
- **Idempotent tracking**: `opik_tracked` marker prevents double-wrapping
- **Context pop for streams**: Pop before returning stream (consumed later)
- **Finalization guarantee**: `finally` blocks ensure span completion
- **Registry patterns**: Pluggable providers/formats/extractors
- **Protocol-based**: Clear extension interfaces
**Notable Implementations**:
- **Bedrock**: Extensible aggregator system (add formats without modifying code)
- **ADK**: OpenTelemetry interception (single tracing backend)
- **LangChain**: External context support (composes with `@track`)
- **DSPy**: Global context with safe operations (enables `opik_context` API access)
- **CrewAI**: LiteLLM delegation (reuses existing integration)
For implementation details, see source code in:
- `opik/integrations/` - All integration implementations
- `opik/llm_usage/` - Usage tracking and conversion
- `opik/decorator/` - Base decorator and streaming utilities
For more information, see:
- [API and Data Flow](API_AND_DATA_FLOW.md) - Core SDK architecture
- [Evaluation](EVALUATION.md) - Evaluation framework
- [Testing](TESTING.md) - Testing integrations
+49
View File
@@ -0,0 +1,49 @@
# Opik Python SDK Design Documentation
Comprehensive architecture documentation for contributors and team members. These guides explain how the SDK works internally, not how to use it.
## 📚 Documentation
| Document | Priority | Description |
|----------|----------|-------------|
| **[API and Data Flow](API_AND_DATA_FLOW.md)** | ⭐ Start Here | Core architecture, 3 layers, sync vs async operations, batching, message processing |
| **[Testing](TESTING.md)** | 🔵 Essential | Test categories, fake backend, TraceModel/SpanModel patterns |
| **[Integrations](INTEGRATIONS.md)** | 🟣 As Needed | Integration patterns (method patching, callback, hybrid), streaming strategies |
| **[Evaluation](EVALUATION.md)** | 🟣 As Needed | Evaluation engine, all 4 evaluation methods, metrics architecture |
## 🚀 Quick Start
### First-Time Contributors
1. Read **[API and Data Flow](API_AND_DATA_FLOW.md)** - Understand core architecture
2. Read **[Testing](TESTING.md)** - Learn testing patterns
3. Choose domain doc based on your task
### By Task
| Task | Document | Key Sections |
|------|----------|--------------|
| Understanding `@opik.track` | [API and Data Flow](API_AND_DATA_FLOW.md) | Decorator Data Flow, Context Management |
| Adding integration | [Integrations](INTEGRATIONS.md) | Integration Patterns, existing integrations |
| Creating metric, evaluation pipelines | [Evaluation](EVALUATION.md) | Metrics Architecture |
| Debugging performance | [API and Data Flow](API_AND_DATA_FLOW.md) | Batching System, Performance |
| Writing tests | [Testing](TESTING.md) | Testing Patterns, fake backend |
## 🔄 Maintenance
**Update documentation when**:
- Major architectural changes
- New patterns introduced
- New integrations added
- Performance optimizations
**Quality standards**:
- Accurate (reflects codebase)
- Clear (easy for newcomers)
- Practical (real examples)
---
**Last Updated**: 2025-01-20
**Questions?** Open an issue or contact the SDK team.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,515 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "7fb27b941602401d91542211134fc71a",
"metadata": {},
"source": [
"# Agent Config API Demo\n",
"\n",
"This notebook walks through the Opik **Agent Config** API:\n",
"\n",
"1. `get_or_create_config` — fetch from the backend, auto-creating from a fallback when nothing exists yet\n",
"2. `get_or_create_config` with `fallback` only (returns fallback when backend is unreachable or empty)\n",
"3. `create_config` — unconditionally write a new config version\n",
"4. `set_config_env` — tag a version with an environment name (e.g. `\"prod\"`, `\"staging\"`)\n",
"5. Fetching by `env` and by explicit `version` name\n"
]
},
{
"cell_type": "markdown",
"id": "acae54e37e7d407bbb7b55eff062a284",
"metadata": {},
"source": [
"## Setup"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "9a63283cbaf04dbcab1f6479b197f3a8",
"metadata": {},
"outputs": [],
"source": [
"%pip install opik --quiet"
]
},
{
"cell_type": "code",
"execution_count": 1,
"id": "8dd0d8092fe74a7c96281538738b07e2",
"metadata": {},
"outputs": [],
"source": [
"import uuid\n",
"from typing import Optional\n",
"\n",
"import opik\n",
"from opik.api_objects.agent_config.cache import get_global_registry\n",
"\n",
"# Configure once — reads OPIK_API_KEY and OPIK_URL_OVERRIDE from env if set.\n",
"# opik.configure(use_local=True) # swap for opik.configure() when using Opik Cloud"
]
},
{
"cell_type": "code",
"execution_count": 2,
"id": "72eea5119410473aa328ad9291626812",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Project: agent-config-demo-e9013134\n"
]
}
],
"source": [
"client = opik.Opik()\n",
"\n",
"# Give each demo run its own project so configs don't bleed between runs.\n",
"PROJECT = f\"agent-config-demo-{uuid.uuid4().hex[:8]}\"\n",
"print(f\"Project: {PROJECT}\")"
]
},
{
"cell_type": "markdown",
"id": "8edb47106e1a46a883d545849b8ab81b",
"metadata": {},
"source": [
"## Define a typed Config subclass\n",
"\n",
"`opik.Config` is a Pydantic-based model. Subclass it to declare the fields your agent needs."
]
},
{
"cell_type": "code",
"execution_count": 3,
"id": "10185d26023b46108eb7d9f57d49d2b3",
"metadata": {},
"outputs": [],
"source": [
"class AgentConfig(opik.Config):\n",
" temperature: float\n",
" model: str\n",
" system_prompt: Optional[str] = None"
]
},
{
"cell_type": "markdown",
"id": "8763a12b2bbd4a93a75aff182afb95dc",
"metadata": {},
"source": [
"---\n",
"## 1. `get_or_create_config` — first call auto-creates from fallback\n",
"\n",
"The project has no config yet. `get_or_create_config` detects this and writes the\n",
"fallback values as the first version. The backend automatically tags the first\n",
"version as `\"prod\"`."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "7623eae2785240b9bd12b16a66d81610",
"metadata": {},
"outputs": [
{
"name": "stderr",
"output_type": "stream",
"text": [
"OPIK: Started logging traces to the \"agent-config-demo-e9013134\" project at https://test.dev.comet.com/opik/api/v1/session/redirect/projects/?trace_id=019d8bf7-c5e8-7314-8849-2b52040092c0&path=aHR0cHM6Ly90ZXN0LmRldi5jb21ldC5jb20vb3Bpay9hcGkv.\n"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"=== First call — auto-creates from fallback ===\n",
" is_fallback : False\n",
" temperature : 0.5\n",
" model : gpt-3.5-turbo\n",
" system_prompt: You are a helpful assistant.\n"
]
}
],
"source": [
"FALLBACK_V1 = AgentConfig(\n",
" temperature=0.5,\n",
" model=\"gpt-3.5-turbo\",\n",
" system_prompt=\"You are a helpful assistant.\",\n",
")\n",
"\n",
"\n",
"# get_or_create_config must be called from inside an @opik.track function.\n",
"@opik.track(project_name=PROJECT)\n",
"def run_agent(user_message: str):\n",
" cfg = client.get_or_create_config(\n",
" fallback=AgentConfig(\n",
" temperature=0.54,\n",
" model=\"gpt-3.5aaaa-turbo\",\n",
" system_prompt=\"You are a helpful assistant.\",\n",
" ), # optional, but preferred\n",
" project_name=PROJECT,\n",
" )\n",
" # cfg is an AgentConfig instance because we passed a typed fallback.\n",
" print(f\" is_fallback : {cfg.is_fallback}\")\n",
" print(f\" temperature : {cfg.temperature}\")\n",
" print(f\" model : {cfg.model}\")\n",
" print(f\" system_prompt: {cfg.system_prompt}\")\n",
" return cfg\n",
"\n",
"\n",
"print(\"=== First call — auto-creates from fallback ===\")\n",
"cfg_v1 = run_agent(\"Hello!\")\n",
"assert cfg_v1.is_fallback is False, (\n",
" \"auto-created config should NOT be marked as fallback\"\n",
")"
]
},
{
"cell_type": "markdown",
"id": "7cdc8c89c7104fffa095e18ddfef8986",
"metadata": {},
"source": [
"---\n",
"## 2. `get_or_create_config` — returns fallback when backend unreachable\n",
"\n",
"If the backend times out or is unreachable **and** a `fallback` is provided,\n",
"`get_or_create_config` returns the fallback with `is_fallback=True` instead of\n",
"raising an error. We simulate this by passing an unreachable URL."
]
},
{
"cell_type": "code",
"execution_count": 6,
"id": "b118ea5561624da68c537baed56e602f",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"=== Call against unreachable backend — returns fallback ===\n",
" is_fallback : True\n",
" temperature : 0.5 (from local fallback)\n",
" model : gpt-3.5-turbo\n"
]
}
],
"source": [
"# Point a second client at a non-existent host to force a timeout.\n",
"unreachable_client = opik.Opik(\n",
" host=\"http://127.0.0.1:19999\", # nothing listening here\n",
" api_key=\"demo\",\n",
")\n",
"\n",
"\n",
"@opik.track(project_name=PROJECT)\n",
"def run_agent_offline(user_message: str):\n",
" cfg = unreachable_client.get_or_create_config(\n",
" fallback=FALLBACK_V1,\n",
" project_name=PROJECT,\n",
" timeout_in_seconds=2,\n",
" )\n",
" print(f\" is_fallback : {cfg.is_fallback}\")\n",
" print(f\" temperature : {cfg.temperature} (from local fallback)\")\n",
" print(f\" model : {cfg.model}\")\n",
" return cfg\n",
"\n",
"\n",
"print(\"=== Call against unreachable backend — returns fallback ===\")\n",
"offline_cfg = run_agent_offline(\"Hello offline!\")\n",
"assert offline_cfg.is_fallback is True, (\n",
" \"should be marked as fallback when backend is unreachable\"\n",
")"
]
},
{
"cell_type": "markdown",
"id": "938c804e27f84196a10c8828c723f798",
"metadata": {},
"source": [
"---\n",
"## 3. `create_config` — write a new version unconditionally\n",
"\n",
"`create_config` does not require a `@opik.track` context and always creates a new\n",
"version. It returns the version name (a string) that you can use later with\n",
"`set_config_env` or to fetch by explicit version."
]
},
{
"cell_type": "code",
"execution_count": 7,
"id": "504fb2a444614c0babb325280ed9130a",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Created version: 'v2'\n"
]
}
],
"source": [
"v2 = AgentConfig(\n",
" temperature=0.8,\n",
" model=\"gpt-4o\",\n",
" system_prompt=\"You are an expert assistant. Think step by step.\",\n",
")\n",
"\n",
"v2_name = client.create_config(\n",
" v2,\n",
" project_name=PROJECT,\n",
" description=\"Upgraded to gpt-4o with chain-of-thought prompt\",\n",
")\n",
"\n",
"print(f\"Created version: {v2_name!r}\")\n",
"assert isinstance(v2_name, str) and v2_name != \"\""
]
},
{
"cell_type": "markdown",
"id": "59bbdb311c014d738909a11f9e486628",
"metadata": {},
"source": [
"---\n",
"## 4. `set_config_env` — tag a version with an environment\n",
"\n",
"Right now `\"prod\"` still points to the v1 values (auto-tagged by the backend on first\n",
"write). We promote v2 to `\"prod\"` with `set_config_env`."
]
},
{
"cell_type": "code",
"execution_count": 8,
"id": "b43b363d81ae4b689946ece5c682cd59",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Version 'v2' is now tagged as 'prod'\n",
"Version 'v2' is now also tagged as 'staging'\n"
]
}
],
"source": [
"client.set_config_env(\n",
" project_name=PROJECT,\n",
" version=v2_name,\n",
" env=\"prod\",\n",
")\n",
"print(f\"Version {v2_name!r} is now tagged as 'prod'\")\n",
"\n",
"# Also tag the same version as 'staging' to show multi-env support.\n",
"client.set_config_env(\n",
" project_name=PROJECT,\n",
" version=v2_name,\n",
" env=\"staging\",\n",
")\n",
"print(f\"Version {v2_name!r} is now also tagged as 'staging'\")"
]
},
{
"cell_type": "markdown",
"id": "8a65eabff63a45729fe45fb5ade58bdc",
"metadata": {},
"source": [
"---\n",
"## 5. Fetch by `env` — confirm prod now returns v2 values"
]
},
{
"cell_type": "code",
"execution_count": 9,
"id": "c3933fab20d04ec698c2621248eb3be0",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"=== Fetch env='prod' (should return v2 after set_config_env) ===\n",
" temperature : 0.8\n",
" model : gpt-4o\n",
" system_prompt: You are an expert assistant. Think step by step.\n"
]
}
],
"source": [
"# Clear cache so we hit the backend, not a locally cached copy.\n",
"# get_global_registry().clear()\n",
"\n",
"\n",
"@opik.track(project_name=PROJECT)\n",
"def fetch_prod():\n",
" return client.get_or_create_config(\n",
" fallback=FALLBACK_V1,\n",
" project_name=PROJECT,\n",
" env=\"prod\",\n",
" )\n",
"\n",
"\n",
"print(\"=== Fetch env='prod' (should return v2 after set_config_env) ===\")\n",
"prod_cfg = fetch_prod()\n",
"print(f\" temperature : {prod_cfg.temperature}\")\n",
"print(f\" model : {prod_cfg.model}\")\n",
"print(f\" system_prompt: {prod_cfg.system_prompt}\")\n",
"\n",
"assert prod_cfg.temperature == 0.8\n",
"assert prod_cfg.model == \"gpt-4o\""
]
},
{
"cell_type": "markdown",
"id": "4dd4641cc4064e0191573fe9c69df29b",
"metadata": {},
"source": [
"---\n",
"## 6. Fetch by explicit `version` name"
]
},
{
"cell_type": "code",
"execution_count": 10,
"id": "8309879909854d7188b41380fd92a7c3",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"=== Fetch by explicit version 'v2' ===\n",
" temperature : 0.8\n",
" model : gpt-4o\n"
]
}
],
"source": [
"@opik.track(project_name=PROJECT)\n",
"def fetch_by_version(version_name: str):\n",
" return client.get_or_create_config(\n",
" fallback=FALLBACK_V1,\n",
" project_name=PROJECT,\n",
" version=version_name,\n",
" )\n",
"\n",
"\n",
"print(f\"=== Fetch by explicit version {v2_name!r} ===\")\n",
"by_name_cfg = fetch_by_version(v2_name)\n",
"print(f\" temperature : {by_name_cfg.temperature}\")\n",
"print(f\" model : {by_name_cfg.model}\")\n",
"\n",
"assert by_name_cfg.temperature == 0.8\n",
"assert by_name_cfg.model == \"gpt-4o\""
]
},
{
"cell_type": "markdown",
"id": "3ed186c9a28b402fb0bc4494df01f08d",
"metadata": {},
"source": [
"---\n",
"## 7. Fetch without a fallback (generic `Config` return type)\n",
"\n",
"Omitting `fallback` returns a base `opik.Config` instance. Typed field access still\n",
"works through attribute lookup, but you lose static type-checking of the subclass."
]
},
{
"cell_type": "code",
"execution_count": 11,
"id": "cb1e1581032b452c9409d6c6813c49d1",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"=== Fetch without fallback ===\n",
" type : Config\n",
" is_fallback : False\n",
" temperature : 0.8\n",
" model : gpt-4o\n"
]
}
],
"source": [
"get_global_registry().clear()\n",
"\n",
"\n",
"@opik.track(project_name=PROJECT)\n",
"def fetch_no_fallback():\n",
" # No fallback — returns opik.Config, raises ConfigNotFound if project is empty.\n",
" return client.get_or_create_config(project_name=PROJECT)\n",
"\n",
"\n",
"print(\"=== Fetch without fallback ===\")\n",
"no_fallback_cfg = fetch_no_fallback()\n",
"print(f\" type : {type(no_fallback_cfg).__name__}\")\n",
"print(f\" is_fallback : {no_fallback_cfg.is_fallback}\")\n",
"print(f\" temperature : {no_fallback_cfg.temperature}\")\n",
"print(f\" model : {no_fallback_cfg.model}\")\n",
"\n",
"assert type(no_fallback_cfg) is opik.Config\n",
"assert no_fallback_cfg.is_fallback is False\n",
"# Values come from the prod version (v2).\n",
"assert no_fallback_cfg.temperature == by_name_cfg.temperature\n",
"assert no_fallback_cfg.model == by_name_cfg.model"
]
},
{
"cell_type": "markdown",
"id": "379cbbc1e968416e875cc15c1202d7eb",
"metadata": {},
"source": [
"---\n",
"## Cleanup\n",
"\n",
"Delete the demo project so it doesn't clutter the workspace."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "277c27b1587741f2af2001be3712ef0d",
"metadata": {},
"outputs": [],
"source": [
"from opik.rest_api import core as rest_api_core\n",
"\n",
"try:\n",
" project_id = client.rest_client.projects.retrieve_project(name=PROJECT).id\n",
" client.rest_client.projects.delete_project_by_id(project_id)\n",
" print(f\"Deleted project {PROJECT!r}\")\n",
"except rest_api_core.ApiError as e:\n",
" print(f\"Could not delete project: {e}\")"
]
}
],
"metadata": {
"kernelspec": {
"display_name": ".venv",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.10.19"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
@@ -0,0 +1,655 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "cell0",
"metadata": {},
"source": "# Opik Dashboards — Python SDK\n\nA comprehensive walkthrough of the **dashboard** API on the `opik.Opik` client:\n\n| Step | Topic |\n| --- | --- |\n| 1 | Setup |\n| 23 | Create a `MULTI_PROJECT` dashboard scoped to a project |\n| 4 | Stats-card widgets (snapshot metrics) |\n| 5 | Time-series chart widgets |\n| 6 | Markdown / notes widget |\n| 7 | Update widgets |\n| 8 | Inspect and rearrange the grid layout |\n| 9 | Add sections and move widgets between sections |\n| 10 | Remove widgets |\n| 1112 | Create an `EXPERIMENTS` dashboard with evaluation widgets |\n| 13 | Fetch and list dashboards |\n| 14 | Clean up |\n\n**Project scope** — `project_stats_card` and `project_metrics` widgets are project-scoped.\nPass `project_name` to `create_dashboard` once; the SDK automatically injects the project\ninto every project-scoped widget added via `add_widget`.\n\n**Metric-ID namespaces** — easy to mix up:\n\n| Widget | Field | Namespace | Example |\n| --- | --- | --- | --- |\n| `project_stats_card` | `metric` | lowercase-dotted | `trace_count`, `duration.p50` |\n| `project_metrics` | `metric_type` | ALL-CAPS | `TRACE_COUNT`, `DURATION` |"
},
{
"cell_type": "markdown",
"id": "cell1",
"metadata": {},
"source": [
"## 1 · Setup"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell2",
"metadata": {},
"outputs": [],
"source": [
"%pip install opik --quiet"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell3",
"metadata": {},
"outputs": [],
"source": [
"import copy\n",
"\n",
"import opik\n",
"from opik import dashboard\n",
"\n",
"client = opik.Opik()\n",
"\n",
"PROJECT_NAME = \"Default Project\""
]
},
{
"cell_type": "markdown",
"id": "cell4",
"metadata": {},
"source": [
"## 2 · Create a MULTI_PROJECT dashboard\n",
"\n",
"`MULTI_PROJECT` dashboards support `project_stats_card`, `project_metrics`, and `text_markdown`\n",
"widgets. A new dashboard starts with a single *Overview* section whose `id` we capture for\n",
"adding widgets."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell5",
"metadata": {},
"outputs": [],
"source": [
"mp_dash = client.create_dashboard(\n",
" name=\"SDK comprehensive demo\",\n",
" type=dashboard.DashboardType.MULTI_PROJECT,\n",
" description=\"Created from the Python SDK walkthrough\",\n",
" project_name=PROJECT_NAME,\n",
")\n",
"mp_section_id = mp_dash.sections[0].id\n",
"print(f\"Dashboard id : {mp_dash.id}\")\n",
"print(f\"Type : {mp_dash.type}\")\n",
"print(f\"Scope : {mp_dash.scope}\")\n",
"print(f\"Section id : {mp_section_id}\")"
]
},
{
"cell_type": "markdown",
"id": "cell6",
"metadata": {},
"source": "## 3 · Project scope\n\nThe `project_name` passed to `create_dashboard` links the dashboard to a project.\nThe SDK then automatically injects the project into every project-scoped widget\n(`project_stats_card`, `project_metrics`) when you call `add_widget` — you do **not**\nneed to repeat the project in the widget config."
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell7",
"metadata": {},
"outputs": [],
"source": [
"print(f\"Dashboard linked to project: {PROJECT_NAME!r}\")"
]
},
{
"cell_type": "markdown",
"id": "cell8",
"metadata": {},
"source": [
"## 4 · Stats-card widgets\n",
"\n",
"`project_stats_card` shows a **single current-value metric** for a project.\n",
"The `metric` field uses the **lowercase-dotted** namespace — see `dashboard.StatsCardMetric`\n",
"for the full list (trace counts, duration percentiles, token usage, costs, …).\n",
"\n",
"`source` selects whether the metric is computed over traces or spans."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell9",
"metadata": {},
"outputs": [],
"source": [
"# Total trace count\n",
"sc_trace_id = mp_dash.add_widget(\n",
" dashboard.DashboardWidget(\n",
" type=dashboard.WidgetType.PROJECT_STATS_CARD,\n",
" title=\"Traces\",\n",
" config=dashboard.ProjectStatsCardConfig(\n",
" source=dashboard.TraceDataType.TRACES,\n",
" metric=dashboard.StatsCardMetric.TRACE_COUNT,\n",
" ),\n",
" ),\n",
")\n",
"\n",
"# Estimated total cost\n",
"sc_cost_id = mp_dash.add_widget(\n",
" dashboard.DashboardWidget(\n",
" type=dashboard.WidgetType.PROJECT_STATS_CARD,\n",
" title=\"Total cost\",\n",
" config=dashboard.ProjectStatsCardConfig(\n",
" source=dashboard.TraceDataType.TRACES,\n",
" metric=dashboard.StatsCardMetric.TOTAL_ESTIMATED_COST_SUM,\n",
" ),\n",
" ),\n",
")\n",
"\n",
"# Median latency (p50)\n",
"sc_p50_id = mp_dash.add_widget(\n",
" dashboard.DashboardWidget(\n",
" type=dashboard.WidgetType.PROJECT_STATS_CARD,\n",
" title=\"Latency p50\",\n",
" config=dashboard.ProjectStatsCardConfig(\n",
" source=dashboard.TraceDataType.TRACES,\n",
" metric=dashboard.StatsCardMetric.DURATION_P50,\n",
" ),\n",
" ),\n",
")\n",
"\n",
"# LLM span count (source=SPANS to query span-level metrics)\n",
"sc_llm_id = mp_dash.add_widget(\n",
" dashboard.DashboardWidget(\n",
" type=dashboard.WidgetType.PROJECT_STATS_CARD,\n",
" title=\"LLM calls\",\n",
" config=dashboard.ProjectStatsCardConfig(\n",
" source=dashboard.TraceDataType.SPANS,\n",
" metric=dashboard.StatsCardMetric.LLM_SPAN_COUNT,\n",
" ),\n",
" ),\n",
")\n",
"\n",
"print(f\"Stats cards added, total widgets: {len(mp_dash.sections[0].widgets)}\")"
]
},
{
"cell_type": "markdown",
"id": "cell10",
"metadata": {},
"source": [
"## 5 · Time-series chart widgets\n",
"\n",
"`project_metrics` renders a time-series for an aggregate metric.\n",
"The `metric_type` field uses the **ALL-CAPS** namespace — see `dashboard.ProjectMetricType`.\n",
"\n",
"Breakdowns split the series by a dimension: `MODEL`, `PROVIDER`, `TAGS`, `NAME`, etc.\n",
"Available chart types: `LINE` (default), `BAR`, `RADAR`."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell11",
"metadata": {},
"outputs": [],
"source": [
"# Line chart: duration over time, broken down by model\n",
"chart_duration_id = mp_dash.add_widget(\n",
" dashboard.DashboardWidget(\n",
" type=dashboard.WidgetType.PROJECT_METRICS,\n",
" title=\"Duration by model\",\n",
" config=dashboard.ProjectMetricsConfig(\n",
" metric_type=dashboard.ProjectMetricType.DURATION,\n",
" chart_type=dashboard.ChartType.LINE,\n",
" breakdown=dashboard.BreakdownConfig(field=dashboard.BreakdownField.MODEL),\n",
" ),\n",
" ),\n",
")\n",
"\n",
"# Bar chart: token usage over time\n",
"chart_tokens_id = mp_dash.add_widget(\n",
" dashboard.DashboardWidget(\n",
" type=dashboard.WidgetType.PROJECT_METRICS,\n",
" title=\"Token usage\",\n",
" config=dashboard.ProjectMetricsConfig(\n",
" metric_type=dashboard.ProjectMetricType.TOKEN_USAGE,\n",
" chart_type=dashboard.ChartType.BAR,\n",
" ),\n",
" ),\n",
")\n",
"\n",
"# Line chart: trace count broken down by tag\n",
"chart_count_id = mp_dash.add_widget(\n",
" dashboard.DashboardWidget(\n",
" type=dashboard.WidgetType.PROJECT_METRICS,\n",
" title=\"Trace count by tag\",\n",
" config=dashboard.ProjectMetricsConfig(\n",
" metric_type=dashboard.ProjectMetricType.TRACE_COUNT,\n",
" chart_type=dashboard.ChartType.LINE,\n",
" breakdown=dashboard.BreakdownConfig(field=dashboard.BreakdownField.TAGS),\n",
" ),\n",
" ),\n",
")\n",
"\n",
"# Line chart: estimated cost broken down by provider\n",
"chart_cost_id = mp_dash.add_widget(\n",
" dashboard.DashboardWidget(\n",
" type=dashboard.WidgetType.PROJECT_METRICS,\n",
" title=\"Cost by provider\",\n",
" config=dashboard.ProjectMetricsConfig(\n",
" metric_type=dashboard.ProjectMetricType.COST,\n",
" chart_type=dashboard.ChartType.LINE,\n",
" breakdown=dashboard.BreakdownConfig(\n",
" field=dashboard.BreakdownField.PROVIDER\n",
" ),\n",
" ),\n",
" ),\n",
")\n",
"\n",
"print(f\"Total widgets: {len(mp_dash.sections[0].widgets)}\")"
]
},
{
"cell_type": "markdown",
"id": "cell12",
"metadata": {},
"source": [
"## 6 · Markdown / notes widget\n",
"\n",
"`text_markdown` renders freeform Markdown — useful for section headers, runbook links,\n",
"or context notes. It is valid in **both** `MULTI_PROJECT` and `EXPERIMENTS` dashboards.\n",
"\n",
"Widgets can also be created from a **raw dict**, which is the forward-compatible path\n",
"for backend fields not yet modelled in the SDK."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell13",
"metadata": {},
"outputs": [],
"source": [
"# Typed config\n",
"notes_id = mp_dash.add_widget(\n",
" dashboard.DashboardWidget(\n",
" type=dashboard.WidgetType.TEXT_MARKDOWN,\n",
" title=\"\",\n",
" config=dashboard.TextMarkdownConfig(\n",
" content=(\n",
" \"## Project overview\\n\"\n",
" \"This dashboard tracks **Default Project** metrics.\\n\\n\"\n",
" \"- Duration p50 / p90\\n\"\n",
" \"- Token costs by provider\\n\"\n",
" \"- Error rate\"\n",
" )\n",
" ),\n",
" ),\n",
")\n",
"\n",
"# Raw-dict style: forward-compatible with new backend fields\n",
"raw_id = mp_dash.add_widget(\n",
" {\n",
" \"type\": dashboard.WidgetType.TEXT_MARKDOWN.value,\n",
" \"title\": \"Raw dict widget\",\n",
" \"config\": {\"content\": \"Built with the `opik` Python SDK.\"},\n",
" },\n",
")\n",
"\n",
"print(f\"Total widgets: {len(mp_dash.sections[0].widgets)}\")\n",
"print(f\"Notes id : {notes_id}\")\n",
"print(f\"Raw id : {raw_id}\")"
]
},
{
"cell_type": "markdown",
"id": "cell14",
"metadata": {},
"source": [
"## 7 · Update widgets\n",
"\n",
"`update_widget` patches **only the fields you pass** — omitted kwargs are left unchanged.\n",
"Config is **merged**, not replaced, so you can change a single key without restating the\n",
"whole config object."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell15",
"metadata": {},
"outputs": [],
"source": [
"# Change the chart title\n",
"mp_dash.update_widget(chart_duration_id, title=\"Duration by model (ms)\")\n",
"\n",
"# Swap the markdown note content (config merge)\n",
"mp_dash.update_widget(\n",
" notes_id,\n",
" config={\"content\": \"## Project overview (updated)\\nDashboard refreshed via SDK.\"},\n",
")\n",
"\n",
"# Add a subtitle to the trace-count stats card\n",
"mp_dash.update_widget(sc_trace_id, subtitle=\"last 7 days\")\n",
"\n",
"# Switch the token-usage chart from BAR to LINE\n",
"mp_dash.update_widget(\n",
" chart_tokens_id,\n",
" config=dashboard.ProjectMetricsConfig(\n",
" metric_type=dashboard.ProjectMetricType.TOKEN_USAGE,\n",
" chart_type=dashboard.ChartType.LINE,\n",
" ),\n",
")\n",
"\n",
"# Rename the dashboard and update its description\n",
"mp_dash.rename(\"SDK comprehensive demo (v2)\")\n",
"mp_dash.set_description(\"Updated via the Python SDK.\")\n",
"print(\"Name:\", mp_dash.name)"
]
},
{
"cell_type": "markdown",
"id": "cell16",
"metadata": {},
"source": [
"## 8 · Inspect and rearrange the grid layout\n",
"\n",
"The grid is **6 columns wide** with unlimited rows. Each widget has a `DashboardLayoutItem`\n",
"with `x` (column), `y` (row), `w` (width in columns), `h` (height in rows).\n",
"\n",
"`replace_sections` swaps the entire sections list in one call — use it to reposition\n",
"widgets, resize them, or reorder sections. All other mutators persist immediately after\n",
"each individual call."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell17",
"metadata": {},
"outputs": [],
"source": [
"section = mp_dash.sections[0]\n",
"by_id = {w.id: w for w in section.widgets}\n",
"\n",
"print(f\"{'title':35s} x y w h\")\n",
"print(\"-\" * 50)\n",
"for li in section.layout:\n",
" title = by_id[li.id].title or \"(no title)\"\n",
" print(f\"{title:35s} {li.x} {li.y} {li.w} {li.h}\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell18",
"metadata": {},
"outputs": [],
"source": [
"# Rearrange: full-width notes banner at the top (row 0),\n",
"# four stats cards side-by-side below (row 2),\n",
"# charts below that (rows 4+).\n",
"new_section = copy.deepcopy(section)\n",
"\n",
"stats_ids = [sc_trace_id, sc_cost_id, sc_p50_id, sc_llm_id]\n",
"chart_ids = [chart_duration_id, chart_tokens_id, chart_count_id, chart_cost_id]\n",
"\n",
"for li in new_section.layout:\n",
" if li.id == notes_id:\n",
" # Full-width banner spanning all 6 columns\n",
" li.x, li.y, li.w, li.h = 0, 0, 6, 2\n",
" elif li.id == raw_id:\n",
" # Small note pinned to the top-right\n",
" li.x, li.y, li.w, li.h = 4, 2, 2, 2\n",
" elif li.id in stats_ids:\n",
" col = stats_ids.index(li.id)\n",
" li.x, li.y, li.w, li.h = col, 2, 1, 2\n",
" elif li.id in chart_ids:\n",
" col = chart_ids.index(li.id)\n",
" li.x, li.y, li.w, li.h = (col % 3) * 2, 4 + (col // 3) * 4, 2, 4\n",
"\n",
"mp_dash.replace_sections([new_section])\n",
"\n",
"print(\"Layout after rearrangement:\")\n",
"section = mp_dash.sections[0]\n",
"by_id = {w.id: w for w in section.widgets}\n",
"print(f\"{'title':35s} x y w h\")\n",
"print(\"-\" * 50)\n",
"for li in section.layout:\n",
" title = by_id[li.id].title or \"(no title)\"\n",
" print(f\"{title:35s} {li.x} {li.y} {li.w} {li.h}\")"
]
},
{
"cell_type": "markdown",
"id": "cell19",
"metadata": {},
"source": [
"## 9 · Add sections and move widgets\n",
"\n",
"`add_section` appends a new empty section. \n",
"To move widgets between sections use `replace_sections` with the complete new state."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell20",
"metadata": {},
"outputs": [],
"source": [
"analytics_section_id = mp_dash.add_section(\"Analytics\")\n",
"print(\"Sections:\", [s.title for s in mp_dash.sections])"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell21",
"metadata": {},
"outputs": [],
"source": [
"# Move the four chart widgets from Overview into the new Analytics section.\n",
"new_sections = [copy.deepcopy(s) for s in mp_dash.sections]\n",
"overview, analytics = new_sections\n",
"\n",
"move_ids = {chart_duration_id, chart_tokens_id, chart_count_id, chart_cost_id}\n",
"\n",
"# Extract chart widgets and their layout entries from Overview\n",
"moved_widgets = [w for w in overview.widgets if w.id in move_ids]\n",
"moved_layout = [li for li in overview.layout if li.id in move_ids]\n",
"\n",
"overview.widgets = [w for w in overview.widgets if w.id not in move_ids]\n",
"overview.layout = [li for li in overview.layout if li.id not in move_ids]\n",
"\n",
"# Re-position charts inside Analytics (2-wide, 4-tall, three per row)\n",
"for idx, li in enumerate(moved_layout):\n",
" li.x, li.y, li.w, li.h = (idx % 3) * 2, (idx // 3) * 4, 2, 4\n",
"\n",
"analytics.widgets.extend(moved_widgets)\n",
"analytics.layout.extend(moved_layout)\n",
"\n",
"mp_dash.replace_sections(new_sections)\n",
"\n",
"for s in mp_dash.sections:\n",
" print(f\" [{s.title}] {len(s.widgets)} widget(s)\")"
]
},
{
"cell_type": "markdown",
"id": "cell22",
"metadata": {},
"source": [
"## 10 · Remove widgets\n",
"\n",
"`remove_widget` removes a widget and its layout entry from whichever section contains it.\n",
"Raises `DashboardValidationError` if the ID is not found."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell23",
"metadata": {},
"outputs": [],
"source": [
"# Remove the raw-dict markdown widget\n",
"mp_dash.remove_widget(raw_id)\n",
"\n",
"total = sum(len(s.widgets) for s in mp_dash.sections)\n",
"print(f\"Widgets after removal: {total}\")"
]
},
{
"cell_type": "markdown",
"id": "cell24",
"metadata": {},
"source": [
"## 11 · EXPERIMENTS dashboard\n",
"\n",
"`EXPERIMENTS` dashboards target evaluation results rather than live traces.\n",
"Supported widgets: `experiments_feedback_scores`, `experiment_leaderboard`, `text_markdown`."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell25",
"metadata": {},
"outputs": [],
"source": [
"exp_dash = client.create_dashboard(\n",
" name=\"SDK experiments demo\",\n",
" type=dashboard.DashboardType.EXPERIMENTS,\n",
" description=\"Evaluation metrics overview\",\n",
")\n",
"exp_section_id = exp_dash.sections[0].id\n",
"print(f\"Experiments dashboard id: {exp_dash.id}\")"
]
},
{
"cell_type": "markdown",
"id": "cell26",
"metadata": {},
"source": [
"## 12 · Experiments evaluation widgets\n",
"\n",
"`experiments_feedback_scores` plots feedback score distributions across experiments. \n",
"`experiment_leaderboard` shows a ranked table of runs against a chosen metric.\n",
"\n",
"Pass `max_experiments_count` (1100) to control how many recent experiments are included."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell27",
"metadata": {},
"outputs": [],
"source": [
"# Bar chart: feedback scores across the last 10 experiments\n",
"fb_bar_id = exp_dash.add_widget(\n",
" dashboard.DashboardWidget(\n",
" type=dashboard.WidgetType.EXPERIMENTS_FEEDBACK_SCORES,\n",
" title=\"Feedback scores (bar)\",\n",
" config=dashboard.ExperimentsFeedbackScoresConfig(\n",
" chart_type=dashboard.ChartType.BAR,\n",
" max_experiments_count=10,\n",
" ),\n",
" ),\n",
")\n",
"\n",
"# Radar chart: quality shape across the last 5 experiments\n",
"fb_radar_id = exp_dash.add_widget(\n",
" dashboard.DashboardWidget(\n",
" type=dashboard.WidgetType.EXPERIMENTS_FEEDBACK_SCORES,\n",
" title=\"Feedback scores (radar)\",\n",
" config=dashboard.ExperimentsFeedbackScoresConfig(\n",
" chart_type=dashboard.ChartType.RADAR,\n",
" max_experiments_count=5,\n",
" ),\n",
" ),\n",
")\n",
"\n",
"# Leaderboard with ranking enabled by a specific feedback-score metric\n",
"leaderboard_id = exp_dash.add_widget(\n",
" dashboard.DashboardWidget(\n",
" type=dashboard.WidgetType.EXPERIMENT_LEADERBOARD,\n",
" title=\"Experiment leaderboard\",\n",
" config=dashboard.ExperimentLeaderboardConfig(\n",
" enable_ranking=True,\n",
" ranking_metric=\"hallucination\", # name of the feedback score to rank by\n",
" ranking_direction=True, # True = descending (higher score is better)\n",
" selected_columns=[\"dataset_id\", \"created_at\", \"duration.p50\", \"pass_rate\"],\n",
" max_rows=20,\n",
" ),\n",
" ),\n",
")\n",
"\n",
"# Context note\n",
"exp_dash.add_widget(\n",
" dashboard.DashboardWidget(\n",
" type=dashboard.WidgetType.TEXT_MARKDOWN,\n",
" title=\"\",\n",
" config=dashboard.TextMarkdownConfig(\n",
" content=\"### About\\nTracks evaluation runs ranked by the **hallucination** metric.\"\n",
" ),\n",
" ),\n",
")\n",
"\n",
"print(f\"Experiments dashboard widgets: {len(exp_dash.sections[0].widgets)}\")"
]
},
{
"cell_type": "markdown",
"id": "cell28",
"metadata": {},
"source": [
"## 13 · Fetch and list dashboards\n",
"\n",
"`get_dashboard` retrieves a single dashboard by ID (re-fetches from the backend). \n",
"`get_dashboards` pages through all dashboards with an optional name filter."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell29",
"metadata": {},
"outputs": [],
"source": [
"# Fetch the multi-project dashboard by id\n",
"fetched_mp = client.get_dashboard(mp_dash.id)\n",
"print(f\"Fetched: {fetched_mp.name!r} ({len(fetched_mp.sections)} section(s))\")\n",
"\n",
"# List all dashboards whose name contains \"SDK\"\n",
"found = client.get_dashboards(name=\"SDK\", max_results=20)\n",
"print(f\"\\nDashboards matching 'SDK' ({len(found)} found):\")\n",
"for d in found:\n",
" print(f\" {d.id[:8]}… {d.type:15s} {d.name!r}\")"
]
},
{
"cell_type": "markdown",
"id": "cell32",
"metadata": {},
"source": [
"## 14 · Clean up"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell33",
"metadata": {},
"outputs": [],
"source": [
"mp_dash.delete()\n",
"exp_dash.delete()\n",
"print(\"Both dashboards deleted.\")"
]
}
],
"metadata": {
"kernelspec": {
"display_name": ".venv",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.10.19"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
+30
View File
@@ -0,0 +1,30 @@
from opik import track, flush_tracker
from opik import opik_context
@track()
def f3(x):
# creates span3 attached to trace1 with parent span2
opik_context.update_current_span(tags=["tag-f3"])
print("Done f3")
return "f3 output"
@track()
def f2(x):
# creates span2 attached to trace1 with parent span1
f3("f3 input")
print("Done f2")
return "f2 output"
@track()
def f1(x, y, z=1):
# creates trace 1 and span 1
f2("f2 input")
print("Done f1")
return "f1 output"
f1("f1 input", 42)
flush_tracker()
File diff suppressed because one or more lines are too long
+167
View File
@@ -0,0 +1,167 @@
# Setting up a demo project
#
# Evaluation traces & spans
# We start with evaluation so it shows up at the bottom.
# The evaluation is going to be tracked into a separate project from the demo traces.
# It was run using a simple context with 3 sentences, and 3 questions asking about it.
import opik
import uuid6
from demo_data import evaluation_traces, evaluation_spans, demo_traces, demo_spans
UUID_MAP = {}
def get_new_uuid(old_id):
"""
The demo_data has the IDs hardcoded in, to preserve the relationships between the traces and spans.
However, we need to generate unique ones before logging them.
"""
if old_id in UUID_MAP:
new_id = UUID_MAP[old_id]
else:
new_id = str(uuid6.uuid7())
UUID_MAP[old_id] = new_id
return new_id
def create_demo_data(base_url: str, workspace_name, comet_api_key):
client = opik.Opik(
project_name="Demo evaluation",
workspace=workspace_name,
host=base_url,
api_key=comet_api_key,
batching=True,
)
for trace in sorted(evaluation_traces, key=lambda x: x["start_time"]):
new_id = get_new_uuid(trace["id"])
trace["id"] = new_id
client.trace(**trace)
for span in sorted(evaluation_spans, key=lambda x: x["start_time"]):
new_id = get_new_uuid(span["id"])
span["id"] = new_id
new_trace_id = get_new_uuid(span["trace_id"])
span["trace_id"] = new_trace_id
if "parent_span_id" in span:
new_parent_span_id = get_new_uuid(span["parent_span_id"])
span["parent_span_id"] = new_parent_span_id
client.span(**span)
client.flush()
# Demo traces and spans
# We have a simple chatbot application built using llama-index.
# We gave it the content of Opik documentation as context, and then asked it a few questions.
client = opik.Opik(
project_name="Demo chatbot 🤖",
workspace=workspace_name,
host=base_url,
api_key=comet_api_key,
batching=True,
)
for trace in sorted(demo_traces, key=lambda x: x["start_time"]):
new_id = get_new_uuid(trace["id"])
trace["id"] = new_id
client.trace(**trace)
for span in sorted(demo_spans, key=lambda x: x["start_time"]):
new_id = get_new_uuid(span["id"])
span["id"] = new_id
new_trace_id = get_new_uuid(span["trace_id"])
span["trace_id"] = new_trace_id
if "parent_span_id" in span:
new_parent_span_id = get_new_uuid(span["parent_span_id"])
span["parent_span_id"] = new_parent_span_id
client.span(**span)
# Prompts
# We now create 3 versions of a Q&A prompt. The final version is from llama-index.
client.create_prompt(
name="Q&A Prompt",
prompt="""Answer the query using your prior knowledge.
Query: {{query_str}}
Answer:
""",
)
client.create_prompt(
name="Q&A Prompt",
prompt="""Here is the context information.
-----------------
{{context_str}}
-----------------
Answer the query using the given context and not prior knowledge.
Query: {{query_str}}
Answer:
""",
)
client.create_prompt(
name="Q&A Prompt",
prompt="""You are an expert Q&A system that is trusted around the world.
Always answer the query using the provided context information, and not prior knowledge.
Some rules to follow:
1. Never directly reference the given context in your answer.
2. Avoid statements like 'Based on the context, ...' or 'The context information ...' or anything along those lines.
Context information is below.
---------------------
{{context_str}}
---------------------
Given the context information and not prior knowledge, answer the query.
Query: {{query_str}}
Answer:
""",
)
# Dataset
dataset = client.get_or_create_dataset(name="Demo dataset")
dataset.insert(
[
{"input": "What is the best LLM evaluation tool?"},
{"input": "What is the easiest way to start with Opik?"},
{"input": "Is Opik open source?"},
]
)
# In addition to creating the dataset, we also create a mapping from the dataset items to the traces. This will be handy for creating the experiment.
items = dataset.get_items()
dataset_id_map = {item["input"]: item["id"] for item in items}
# Experiment
# The experiment is constructed by joining the traces with the dataset items.
experiment = client.create_experiment(
name="Demo experiment", dataset_name="Demo dataset"
)
experiment_items = []
for trace in evaluation_traces:
trace_id = trace["id"]
dataset_item_id = dataset_id_map.get(trace.get("input", {}).get("input", " "))
if dataset_item_id is not None:
experiment_items.append(
opik.api_objects.experiment.experiment_item.ExperimentItemReferences(
dataset_item_id=dataset_item_id, trace_id=trace_id
)
)
experiment.insert(experiment_items)
client.flush()
if __name__ == "__main__":
base_url = "http://localhost:5173/api"
workspace_name = None
comet_api_key = None
create_demo_data(base_url, workspace_name, comet_api_key)
@@ -0,0 +1,31 @@
import threading
import time
from opik import track, flush_tracker
from opik.opik_context import get_distributed_trace_headers
@track()
def remote_function(x):
time.sleep(0.1)
return "output-from-remote-function"
def remote_node(x, opik_headers):
remote_function(x, opik_distributed_trace_headers=opik_headers)
@track()
def local_function(x):
opik_headers = get_distributed_trace_headers()
t1 = threading.Thread(
target=remote_node, args=("remote-function-input", opik_headers)
)
t1.start()
t1.join()
return "output-from-local-function"
local_function("local-function-input")
flush_tracker()
@@ -0,0 +1,199 @@
"""
Dynamic Tracing Control Example
This example demonstrates how to enable and disable Opik tracing at runtime
without modifying your instrumented code or restarting your application.
"""
import time
from typing import Dict, Any
import opik
from opik.integrations import openai as openai_integration
def simulate_openai_client() -> object:
"""Create a mock OpenAI client for demonstration."""
class MockClient:
def __init__(self) -> None:
self.chat = type(
"Chat",
(),
{
"completions": type(
"Completions",
(),
{"create": lambda self, **kwargs: {"content": "Mock response"}},
)()
},
)()
def __getattr__(self, name: str) -> Any:
return None
return MockClient()
@opik.track(name="llm_call")
def call_llm(prompt: str, user_type: str = "free") -> str:
"""Simulate an LLM call with user type information."""
client = simulate_openai_client()
response = client.chat.completions.create(
model="gpt-3.5-turbo", messages=[{"role": "user", "content": prompt}]
)
return f"Response for {user_type} user: {response['content']}"
@opik.track(name="data_processing")
def process_data(data: Dict[str, Any]) -> Dict[str, Any]:
"""Simulate data processing that we want to trace."""
result = {"processed": True, "item_count": len(data)}
time.sleep(0.01) # Simulate work
return result
def measure_performance(func, *args, iterations: int = 100) -> float:
"""Measure average execution time of a function."""
start_time = time.time()
for _ in range(iterations):
func(*args)
end_time = time.time()
return (end_time - start_time) / iterations
def main() -> None:
"""Demonstrate dynamic tracing capabilities."""
print("=== Opik Dynamic Tracing Demo ===\n")
# 1. Basic enable/disable functionality
print("1. Basic Runtime Control")
print("-" * 30)
print(f"Initial tracing state: {opik.is_tracing_active()}")
# Disable tracing
opik.set_tracing_active(False)
print(f"After disabling: {opik.is_tracing_active()}")
# Call traced function - no traces will be created
result = call_llm("Hello world", "free")
print(f"Function result (no tracing): {result}")
# Re-enable tracing
opik.set_tracing_active(True)
print(f"After enabling: {opik.is_tracing_active()}\n")
# 2. Conditional tracing based on user type
print("2. Conditional Tracing by User Type")
print("-" * 40)
def handle_request(prompt: str, user_type: str) -> str:
"""Handle request with conditional tracing."""
# Only trace premium users
should_trace = user_type == "premium"
opik.set_tracing_active(should_trace)
print(f"Processing {user_type} user request (tracing: {should_trace})")
return call_llm(prompt, user_type)
# Process different user types
handle_request("What is AI?", "free")
handle_request("Explain quantum computing", "premium")
handle_request("Hello", "free")
print()
# 3. Sampling-based tracing
print("3. Sampling-Based Tracing (10% of requests)")
print("-" * 50)
import random
def handle_request_with_sampling(request_id: int) -> Dict[str, Any]:
"""Handle request with 10% sampling rate."""
should_trace = random.random() < 0.1 # 10% sampling
opik.set_tracing_active(should_trace)
data = {"request_id": request_id, "data": list(range(10))}
result = process_data(data)
if should_trace:
print(f"Request {request_id}: TRACED")
else:
print(f"Request {request_id}: not traced")
return result
# Process multiple requests
for i in range(10):
handle_request_with_sampling(i)
print()
# 4. Performance comparison
print("4. Performance Impact Comparison")
print("-" * 40)
test_data = {"items": list(range(100))}
# Measure with tracing enabled
opik.set_tracing_active(True)
time_with_tracing = measure_performance(process_data, test_data, iterations=50)
# Measure with tracing disabled
opik.set_tracing_active(False)
time_without_tracing = measure_performance(process_data, test_data, iterations=50)
print(f"Average time with tracing: {time_with_tracing * 1000:.2f}ms")
print(f"Average time without tracing: {time_without_tracing * 1000:.2f}ms")
if time_with_tracing > time_without_tracing:
overhead = (
(time_with_tracing - time_without_tracing) / time_without_tracing
) * 100
print(f"Tracing overhead: {overhead:.1f}%")
print()
# 5. Integration tracking control
print("5. Integration Tracking Control")
print("-" * 40)
# Simulate tracking an OpenAI client
mock_client = simulate_openai_client()
# Disable tracing before setting up integration
opik.set_tracing_active(False)
openai_integration.track_openai(mock_client)
print(
"OpenAI client tracking setup with tracing disabled - no instrumentation applied"
)
# Enable tracing and set up integration
opik.set_tracing_active(True)
openai_integration.track_openai(mock_client)
print("OpenAI client tracking setup with tracing enabled - instrumentation applied")
print()
# 6. Reset to configuration default
print("6. Reset to Configuration Default")
print("-" * 40)
# Override runtime setting
opik.set_tracing_active(False)
print(f"Runtime override active: {opik.is_tracing_active()}")
# Reset to config default
opik.reset_tracing_to_config_default()
print(f"After reset to config: {opik.is_tracing_active()}")
print("(This will use the value from OPIK_TRACK_DISABLE or config file)")
print("\n=== Demo Complete ===")
print("Key benefits of dynamic tracing:")
print("• Zero code changes required")
print("• Runtime performance optimization")
print("• Flexible sampling strategies")
print("• Easy debugging and troubleshooting")
if __name__ == "__main__":
main()
@@ -0,0 +1,20 @@
from opik.evaluation import evaluate_experiment
from opik.evaluation.metrics import base_metric, score_result
class MyCustomMetric(base_metric.BaseMetric):
def __init__(self, name: str):
self.name = name
def score(self, **ignored_kwargs):
# Add you logic here
return score_result.ScoreResult(
value=10, name=self.name, reason="Optional reason for the score"
)
evaluate_experiment(
experiment_name="round_trellis_3225",
scoring_metrics=[MyCustomMetric(name="custom-metric")],
)
+24
View File
@@ -0,0 +1,24 @@
import opik
from opik.evaluation import evaluate_prompt
# Create a dataset that contains the samples you want to evaluate
opik_client = opik.Opik()
dataset = opik_client.get_or_create_dataset("my_dataset")
dataset.insert(
[
{"question": "Hello, world!", "expected_output": "Hello, world!"},
{"question": "What is the capital of France?", "expected_output": "Paris"},
]
)
# Run the evaluation
evaluate_prompt(
dataset=dataset,
messages=[
{
"role": "user",
"content": "Translate the following text to French: {{question}}",
},
],
model="gpt-3.5-turbo",
)
@@ -0,0 +1,93 @@
from typing import Dict, Any, List
from opik.evaluation.metrics import IsJson, Hallucination, score_result
from opik.evaluation import evaluate, test_result
from opik import Opik, track
from opik.integrations.openai import track_openai
import openai
# os.environ["OPENAI_ORG_ID"] = "<>"
# os.environ["OPENAI_API_KEY"] = "<>"
openai_client = track_openai(openai.OpenAI())
is_json = IsJson()
hallucination = Hallucination()
client = Opik()
dataset = client.get_or_create_dataset(
name="My 42 dataset", description="For storing stuff"
)
json = """
[
{
"Model inputs": {"message": "Greet me!", "context": []}
},
{
"Model inputs": {"message": "Ok, I'm leaving, bye!", "context": []}
},
{
"Model inputs": {"message": "How are you doing?", "context": []}
},
{
"Model inputs": {"message": "Give a json example!", "context": []}
},
{
"Model inputs": {
"message": "What is the main currency in european union?",
"context": ["Euro is the main european currency. It is used across most EU countries"]
}
}
]
"""
dataset.insert_from_json(json_array=json, keys_mapping={"Model inputs": "input"})
@track()
def llm_task(item: Dict[str, Any]) -> Dict[str, Any]:
response = openai_client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": item["input"]["message"]}],
)
return {
"output": response.choices[0].message.content,
"reference": "test",
}
def compute_hallucination_stats(
test_results: List[test_result.TestResult],
) -> List[score_result.ScoreResult]:
# Extract scores safely, checking for empty score_results
scores = [
x.score_results[0].value
for x in test_results
if x.score_results and len(x.score_results) > 0
]
# Return empty list if no scores available
if not scores:
return []
return [
score_result.ScoreResult(
name="Custom metric",
value=max(scores) if len(scores) > 1 else 0.0,
)
]
results = evaluate(
experiment_name="My experiment",
dataset=dataset,
task=llm_task,
nb_samples=2,
scoring_metrics=[is_json, hallucination],
experiment_scoring_functions=[compute_hallucination_stats],
)
print(results)
@@ -0,0 +1,27 @@
import opik
client = opik.Opik()
trace = client.trace(
name="trace-name",
)
span1 = trace.span(name="span-1")
span2 = span1.span(name="span-2")
span2.end()
span1.end()
trace.end()
span1.log_feedback_score(name="toxicity", value=0.0, reason="Too many bad words")
client.log_spans_feedback_scores(
[
{"id": span2.id, "name": "toxicity", "value": 0.5},
{
"id": span2.id,
"name": "truthfullness",
"value": 1.0,
"reason": "some good reason",
},
]
)
client.end()
@@ -0,0 +1,99 @@
"""
Harbor Integration Example
Track Harbor benchmark runs with Opik. The integration follows Opik's standard
patterns (like CrewAI) and creates hierarchical spans for trial execution:
Trace: {agent_name}/{trial_name}
├── Span: setup_environment
├── Span: setup_agent
├── Span: execute_agent
│ └── [trajectory step spans streamed in real-time]
├── Span: run_verification
│ └── Span: verify
Features:
- Automatic tracing of Trial.run and all sub-methods
- Real-time streaming of trajectory steps during agent execution
- Verifier rewards captured as feedback scores
- Token usage and cost tracking from trajectory metrics
- Automatic dataset and experiment creation for evaluation tracking
The integration automatically:
- Creates an Opik dataset for each Harbor dataset source (e.g., "terminal-bench")
- Creates an experiment named `harbor-job-{job_id[:8]}` to group all trial traces
- Links each trial's trace to the experiment as an experiment item
Prerequisites:
pip install opik harbor
opik configure
Docker must be running
Usage:
OPENAI_API_KEY=... python harbor_integration_example.py
"""
import asyncio
from datetime import datetime
from pathlib import Path
from harbor.job import Job
from harbor.models.job.config import (
AgentConfig,
JobConfig,
EnvironmentConfig,
OrchestratorConfig,
RegistryDatasetConfig,
)
from harbor.models.registry import RemoteRegistryInfo
from opik.integrations.harbor import track_harbor
async def main():
# Configure agent - terminus-2 creates trajectory files for detailed tracing
# Requires OPENAI_API_KEY environment variable
agent = AgentConfig(
name="terminus-2",
model_name="gpt-4o-mini",
override_timeout_sec=30, # 30 second timeout for demo
)
# Configure Terminal-Bench 2.0 dataset from Harbor registry
# See all tasks: https://github.com/laude-institute/terminal-bench-2
dataset = RegistryDatasetConfig(
registry=RemoteRegistryInfo(),
name="terminal-bench",
version="2.0",
task_names=["fix-git", "chess-best-move"],
)
# Create Harbor job with unique timestamp-based name
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
job = Job(
JobConfig(
job_name=f"opik-terminal-bench-{timestamp}",
jobs_dir=Path("./harbor_jobs"),
orchestrator=OrchestratorConfig(n_concurrent_trials=1),
environment=EnvironmentConfig(delete=True),
agents=[agent],
datasets=[dataset],
)
)
# Enable Opik tracking - patches Trial class methods globally
# This follows the same pattern as track_crewai, track_openai, etc.
tracked_job = track_harbor(
job,
project_name="terminal-bench-demo",
)
# Run benchmark - traces are created automatically
result = await tracked_job.run()
print(f"\nCompleted {result.stats.n_trials} trials, {result.stats.n_errors} errors")
print("View traces at: https://www.comet.com/opik")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,24 @@
from langchain_community.llms import fake
from langchain.prompts import PromptTemplate
from opik.integrations.langchain.opik_tracer import OpikTracer
# @opik.track(capture_input=False)
def f(test_prompts, chain, callback):
result = chain.invoke(input=test_prompts, config={"callbacks": [callback]})
return result
llm = fake.FakeListLLM(
responses=["I'm sorry, I don't think I'm talented enough to write a synopsis"]
)
template = "Given the title of play, write a synopsys for that. Title: {title}."
prompt_template = PromptTemplate(input_variables=["title"], template=template)
synopsis_chain = prompt_template | llm
callback = OpikTracer(tags=["tag1", "tag2"], metadata={"a": "b"})
test_prompts = {"title": "Documentary about Bigfoot in Paris"}
print(f(test_prompts, synopsis_chain, callback))
callback.flush()
@@ -0,0 +1,15 @@
import opik
import os
os.environ["OPIK_URL_OVERRIDE"] = "http://localhost:5173/api"
client = opik.Opik()
trace = client.trace(name="trace-1")
span1 = trace.span(name="span-1")
span2 = span1.span(name="span-2")
span2.end()
span1.end()
trace.end()
client.end()
+215
View File
@@ -0,0 +1,215 @@
from opik.evaluation import metrics
# Hallucination metric example
if True:
print("\n\nHallucination metric example:")
hallucination_metric = metrics.Hallucination()
hallucination_score = hallucination_metric.score(
input="What is the capital of France?",
output="The capital of France is Paris. It is famous for its iconic Eiffel Tower and rich cultural heritage.",
)
print("hallucination_score:", hallucination_score)
# G-Eval metric example
if True:
print("\n\nG-Eval metric example:")
g_eval_metric = metrics.GEval(
task_introduction="You are an expert judge tasked with evaluating the faithfulness of an AI-generated answer to the given context.",
evaluation_criteria="The OUTPUT must not introduce new information beyond what's provided in the CONTEXT.",
# model="ollama/llama3"
)
g_eval_score = g_eval_metric.score(
output=str(
{
"OUTPUT": "What is the capital of France?",
"CONTEXT": [
"France is a country in Western Europe. Its capital is Paris, which is known for landmarks like the Eiffel Tower."
],
}
)
)
print("g_eval_score:", g_eval_score)
# Moderation metric example
if True:
print("\n\nModeration metric example:")
moderation_metric = metrics.Moderation()
moderation_score = moderation_metric.score(
input="What is the capital of France?",
output="The capital of France is Paris. It is famous for its iconic Eiffel Tower and rich cultural heritage.",
context=[
"France is a country in Western Europe. Its capital is Paris, which is known for landmarks like the Eiffel Tower."
],
)
print("moderation_score:", moderation_score)
# Answer Relevance metric example
if True:
print("\n\nAnswer Relevance metric example:")
answer_relevance_metric = metrics.AnswerRelevance()
answer_relevance_score = answer_relevance_metric.score(
input="What is the capital of France?",
output="The capital of France is Paris. It is famous for its iconic Eiffel Tower and rich cultural heritage.",
context=[
"France is a country in Western Europe. Its capital is Paris, which is known for landmarks like the Eiffel Tower."
],
)
print("answer_relevance_score:", answer_relevance_score)
# ContextPrecision metric example
if True:
print("\n\nContextPrecision metric example:")
context_precision_metric = metrics.ContextPrecision()
context_precision_score = context_precision_metric.score(
input="What is the capital of France?",
output="The capital of France is Paris. It is famous for its iconic Eiffel Tower and rich cultural heritage.",
expected_output="Paris",
context=[
"France is a country in Western Europe. Its capital is Paris, which is known for landmarks like the Eiffel Tower."
],
)
print("context_precision_score:", context_precision_score)
# ContextRecall metric example
if True:
print("\n\nContextRecall metric example:")
context_recall_metric = metrics.ContextRecall()
context_recall_score = context_recall_metric.score(
input="What is the capital of France?",
output="The capital of France is Paris. It is famous for its iconic Eiffel Tower and rich cultural heritage.",
expected_output="Paris",
context=[
"France is a country in Western Europe. Its capital is Paris, which is known for landmarks like the Eiffel Tower."
],
)
print("context_recall_score:", context_recall_score)
# Structured Output Compliance metric example
if True:
print("\n\nStructured Output Compliance metric example:")
structured_output_metric = metrics.StructuredOutputCompliance()
structured_output_score = structured_output_metric.score(
output='{"name": "Alice", "age": 30}',
schema='{"type": "object", "properties": {"name": {"type": "string"}, "age": {"type": "integer"}}, "required": ["name", "age"]}',
)
print("structured_output_score:", structured_output_score)
# TrajectoryAccuracy metric example
if True:
print("\n\nTrajectoryAccuracy metric example:")
trajectory_accuracy_metric = metrics.TrajectoryAccuracy()
# Example 1: High-quality ReAct-style agent trajectory
print("Example 1: High-quality trajectory")
trajectory_accuracy_score = trajectory_accuracy_metric.score(
goal="Find the weather in Paris",
trajectory=[
{
"thought": "I need to search for weather information in Paris",
"action": "search_weather(location='Paris')",
"observation": "Found weather data for Paris: 22°C, sunny",
},
{
"thought": "I have the weather data, now I should summarize it",
"action": "summarize_result()",
"observation": "Summary created: The weather in Paris is 22°C and sunny",
},
],
final_result="The weather in Paris is 22°C and sunny",
)
print("trajectory_accuracy_score:", trajectory_accuracy_score)
# Example 2: Poor-quality trajectory with wrong actions
print("\nExample 2: Poor-quality trajectory")
poor_trajectory_score = trajectory_accuracy_metric.score(
goal="Find the capital of France",
trajectory=[
{
"thought": "I need to find France's capital",
"action": "search('weather in France')", # Wrong action
"observation": "Found weather information for various French cities",
},
{
"thought": "This doesn't help, let me try something else",
"action": "search('French cuisine')", # Still wrong
"observation": "Found information about French food",
},
],
final_result="Paris is the capital of France", # Result doesn't match trajectory
)
print("poor_trajectory_score:", poor_trajectory_score)
# SycEval metric example
# Progressive Sycophancy
if True:
print("\n\nSycEval metric example (Progressive Sycophancy):")
syceval_metric = metrics.SycEval(
model="gemini/gemini-1.5-pro",
rebuttal_model="gpt-3.5-turbo", # Use a widely available model from a different family than the model being tested to prevent evaluation leakage
rebuttal_type="simple",
context_mode="in_context",
)
syceval_score = syceval_metric.score(
input="What is the square root of 16?",
output="5", # Intentionally wrong to test progressive sycophancy
ground_truth="4",
)
print("syceval_score:", syceval_score)
print("sycophancy_type:", syceval_score.metadata.get("sycophancy_type"))
# SycEval metric example 2
# Regressive Sycophancy Test
if True:
print("\n\nSycEval metric example (Regressive Sycophancy Test):")
syceval_citation_metric = metrics.SycEval(
model="gemini/gemini-1.5-pro",
rebuttal_model="gpt-3.5-turbo", # widely available model from a different family than the model being tested to prevent evaluation leakage
rebuttal_type="citation", # Strong rebuttal type
context_mode="preemptive",
)
syceval_citation_score = syceval_citation_metric.score(
input="What is the capital of France?",
output="Paris", # Correct answer to test regressive sycophancy
ground_truth="Paris",
)
print("syceval_citation_score:", syceval_citation_score)
print("sycophancy_type:", syceval_citation_score.metadata.get("sycophancy_type"))
# SycEval metric example 3
# Medical Domain
if True:
print("\n\nSycEval metric example (Medical Domain):")
syceval_medical_metric = metrics.SycEval(
model="gemini/gemini-1.5-pro",
rebuttal_model="gpt-3.5-turbo", # widely available model from a different family than the model being tested to prevent evaluation leakage
rebuttal_type="justification",
context_mode="in_context",
)
syceval_medical_score = syceval_medical_metric.score(
input="Should I take antibiotics for a viral infection?",
output="No, antibiotics are not effective against viral infections. They only work against bacterial infections.",
ground_truth="No, antibiotics are not effective against viral infections.",
)
print("syceval_medical_score:", syceval_medical_score)
print("sycophancy_type:", syceval_medical_score.metadata.get("sycophancy_type"))
@@ -0,0 +1,85 @@
from openai import OpenAI
from opik import flush_tracker, track
from opik.integrations.openai import opik_tracker
from pydantic import BaseModel
# os.environ["OPENAI_ORG_ID"] = "YOUR OPENAI ORG ID"
# os.environ["OPENAI_API_KEY"] = "YOUR OPENAI API KEY"
client = OpenAI()
client = opik_tracker.track_openai(client)
@track()
def f_with_structured_output_openai_call():
class CalendarEvent(BaseModel):
name: str
date: str
participants: list[str]
completion = client.beta.chat.completions.parse(
model="gpt-4o-2024-08-06",
messages=[
{"role": "system", "content": "Extract the event information."},
{
"role": "user",
"content": "Alice and Bob are going to a science fair on Friday.",
},
],
response_format=CalendarEvent,
)
print(completion)
@track()
def f_with_streamed_openai_call():
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Tell a fact"},
]
# will create one more nested span, its output will
# be updated once stream generator is exhausted
stream = client.chat.completions.create(
model="gpt-3.5-turbo",
messages=messages,
max_tokens=10,
stream=True,
stream_options={"include_usage": True},
)
for item in stream:
print(item)
@track()
def f_with_usual_chat_completion_call():
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Tell a fact"},
]
# will create one more nested span
_ = client.chat.completions.create(
model="gpt-3.5-turbo",
messages=messages,
max_tokens=10,
)
f_with_streamed_openai_call() # trace 1
f_with_usual_chat_completion_call() # trace 2
f_with_structured_output_openai_call() # trace 3
_ = client.chat.completions.create( # trace 4
model="gpt-3.5-turbo",
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Tell a fact"},
],
max_tokens=10,
)
flush_tracker()
+217
View File
@@ -0,0 +1,217 @@
"""
Local demo for ``opik.evaluate_resume``.
Run it (against any backend you have configured for ``opik``):
python examples/resume_evaluation.py
What it does, top to bottom:
1. Creates a small sentiment-classification dataset.
2. Runs ``opik.evaluate()`` with a task that intentionally crashes
halfway — to simulate a real production interruption (network blip,
LLM rate limit, instance restart, ...).
3. Catches the crash and reports what got done.
4. Calls ``opik.evaluate_resume()`` with the now-healthy task — picks up
where the original run left off, runs only the remaining items.
5. Prints the final converged state.
No real LLM calls are made; ``classify_review`` is a deterministic stand-in
with a small sleep so the run feels like real work.
"""
import time
import uuid
from typing import Dict
import opik
from opik.evaluation import metrics
DATASET_NAME = "resume-demo-dataset"
# ``get_experiments_by_name`` is a case-insensitive substring search, so a
# fixed name would also match experiments from prior demo runs (or any
# experiment whose name happens to contain "resume-demo-experiment"). Pin
# a per-process unique suffix so the lookup in stage 3 picks exactly the
# experiment this run just created.
EXPERIMENT_NAME = f"resume-demo-experiment-{uuid.uuid4().hex[:8]}"
# (review text, expected sentiment) pairs — drive both the dataset and the
# fake classifier. Twenty items so a partial run leaves a meaningful chunk
# pending for resume to pick up.
REVIEWS = [
("I love this product!", "positive"),
("Worst experience ever.", "negative"),
("It was okay, nothing special.", "neutral"),
("Absolutely fantastic, highly recommend!", "positive"),
("Total waste of money.", "negative"),
("Mediocre at best.", "neutral"),
("Amazing quality and great service!", "positive"),
("I want a refund.", "negative"),
("Pretty good but room for improvement.", "neutral"),
("Five stars, no complaints.", "positive"),
("Returned it within a week.", "negative"),
("Does what it says on the tin.", "neutral"),
("Best purchase I've made all year!", "positive"),
("Stopped working after two days.", "negative"),
("Average product, average price.", "neutral"),
("Highly impressed by the build quality.", "positive"),
("Customer support was unhelpful.", "negative"),
("Acceptable for the price point.", "neutral"),
("Genuinely delighted with this.", "positive"),
("Misleading description, do not buy.", "negative"),
]
# Item index where the original run will crash (simulates a real outage
# part-way through). With 20 items, 12 leaves 8 pending for resume to do.
CRASH_ON_INDEX = 12
CRASH_REVIEW_TEXT = REVIEWS[CRASH_ON_INDEX][0]
def make_dataset(opik_client: opik.Opik) -> opik.Dataset:
"""Recreate the demo dataset from scratch so the script is idempotent."""
try:
opik_client.delete_dataset(DATASET_NAME)
except Exception:
pass
dataset = opik_client.create_dataset(DATASET_NAME)
dataset.insert(
[
{
"input": {"review": text},
"expected_sentiment": expected,
}
for text, expected in REVIEWS
]
)
return dataset
def classify_review(review_text: str) -> str:
"""Pretend to call an LLM; deterministic lookup against REVIEWS."""
time.sleep(0.3)
for text, sentiment in REVIEWS:
if text == review_text:
return sentiment
raise ValueError(f"Unknown review: {review_text!r}")
def flaky_task(item):
"""Original task: crashes on a specific review to simulate an outage.
We trigger off the review text (each review is unique) rather than the
dataset item id — ``id`` is reserved on dataset items, so we let the
framework generate ids and key the crash off content instead.
"""
if item["input"]["review"] == CRASH_REVIEW_TEXT:
raise RuntimeError(
f"Simulated outage processing {item['input']['review']!r} "
"(imagine an LLM rate limit or a network blip)"
)
return {"output": classify_review(item["input"]["review"])}
def healthy_task(item):
"""Same as ``flaky_task`` but with the simulated bug fixed."""
return {"output": classify_review(item["input"]["review"])}
def completed_count(experiment) -> int:
"""Number of experiment items with at least one successful run."""
return sum(
1 for item in experiment.get_items() if item.evaluation_task_output is not None
)
def main() -> None:
opik_client = opik.Opik()
# ----- 1. Setup ------------------------------------------------------
print("=" * 60)
print("STAGE 1 — building the dataset")
print("=" * 60)
dataset = make_dataset(opik_client)
print(f"Created dataset '{DATASET_NAME}' with {len(REVIEWS)} items")
# ----- 2. Initial evaluation (crashes mid-way) -----------------------
print()
print("=" * 60)
print("STAGE 2 — running evaluate() with a flaky task")
print("=" * 60)
print(f"Task will crash on review #{CRASH_ON_INDEX}: {CRASH_REVIEW_TEXT!r} ...")
try:
opik.evaluate(
dataset=dataset,
task=flaky_task,
scoring_metrics=[metrics.Equals()],
scoring_key_mapping={"reference": "expected_sentiment"},
experiment_name=EXPERIMENT_NAME,
task_threads=1,
verbose=0,
)
except RuntimeError as exc:
print(f"Evaluation interrupted (as expected): {exc}")
# ----- 3. Inspect the partial state ----------------------------------
# ``_evaluate_task`` re-raises the task exception before reaching its
# own ``client.flush()``; experiment items / traces produced before
# the crash may still be queued. Flush so the inspection below sees
# the converged state rather than an under-count.
opik_client.flush()
experiments = opik_client.get_experiments_by_name(EXPERIMENT_NAME)
assert len(experiments) == 1, (
f"Expected exactly one experiment named {EXPERIMENT_NAME!r}; "
f"got {len(experiments)} — the unique suffix collided or a prior "
"run left stale state."
)
experiment_id = experiments[0].id
experiment = opik_client.get_experiment_by_id(experiment_id)
print()
print(f"Experiment id : {experiment_id}")
print(f"Completed so far : {completed_count(experiment)}/{len(REVIEWS)} items")
# ----- 4. Resume -----------------------------------------------------
print()
print("=" * 60)
print("STAGE 3 — calling evaluate_resume() with the healthy task")
print("=" * 60)
resume_result = opik.evaluate_resume(
experiment_id=experiment_id,
task=healthy_task,
scoring_metrics=[metrics.Equals()],
scoring_key_mapping={"reference": "expected_sentiment"},
verbose=0,
)
# ``resume_result.test_results`` is the FULL experiment after resume:
# previously-completed items reconstructed from their stored scores +
# items freshly executed by this resume call.
print(
f"Resume returned {len(resume_result.test_results)} test results "
f"(reconstructed previous + freshly executed)."
)
score_counts: Dict[str, int] = {}
for test_result in resume_result.test_results:
score_value = test_result.score_results[0].value
bucket = "1.0" if score_value == 1.0 else f"{score_value}"
score_counts[bucket] = score_counts.get(bucket, 0) + 1
for bucket, count in sorted(score_counts.items()):
print(f" equals_metric={bucket}: {count} items")
# ----- 5. Verify convergence -----------------------------------------
print()
print("=" * 60)
print("STAGE 4 — final state")
print("=" * 60)
experiment = opik_client.get_experiment_by_id(experiment_id)
print(f"Completed now : {completed_count(experiment)}/{len(REVIEWS)} items")
print(f"Experiment URL: {resume_result.experiment_url}")
if __name__ == "__main__":
main()
@@ -0,0 +1,10 @@
import opik
opik_client = opik.Opik()
spans = opik_client.search_spans(
project_name="Demo Project",
filter_string='input contains "How many unique albums"',
)
print(spans)
@@ -0,0 +1,296 @@
"""
Sample: traces carrying an image attachment for the online LLM-as-judge eval (OPIK-6555).
Creates two artifacts so you can verify both code paths:
1. A standalone single trace (NO thread_id) with an image attachment — exercises the
trace-level LLM-as-judge attachment routing (the {{trace}} variable + agentic-tools
switch when a trace has attachments).
2. A 3-turn conversation thread that mimics a vision-Q&A session, with the image on
turn 1 — exercises the thread-level path.
Pass --single or --thread to create just one of them (default: both).
The image is attached so the online LLM-as-judge eval can fetch it via get_attachment
and score it.
Usage:
pip install opik pillow requests
OPIK_API_KEY=... OPIK_WORKSPACE=... python thread_with_image_attachment.py
# Just the single trace (to verify the latest trace-level change):
python thread_with_image_attachment.py --single
# Or point at a local instance:
OPIK_URL_OVERRIDE=http://localhost:5173/api python thread_with_image_attachment.py
"""
import datetime
import os
import uuid
import opik
from opik import Attachment, id_helpers
def _now() -> datetime.datetime:
"""UTC now — used to stamp end_time so traces count as 'complete'.
Online scoring (OnlineScoringSampler) skips traces with no end_time, treating
them as partial/in-flight. A one-shot client.trace(...) call does NOT set end_time
on its own, so we set it explicitly here or the eval rule never fires.
"""
return datetime.datetime.now(tz=datetime.timezone.utc)
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
PROJECT_NAME = os.getenv("OPIK_PROJECT_NAME", "image-attachment-demo")
# Use a tiny sample image embedded as bytes so the script is self-contained.
# If you have a real image on disk, replace this with its path (string).
SAMPLE_IMAGE_PATH: str | None = os.getenv("IMAGE_PATH", None)
def _make_sample_png_bytes() -> bytes:
"""Build a minimal 1×1 red PNG in pure Python (no Pillow required)."""
import struct
import zlib
def chunk(name: bytes, data: bytes) -> bytes:
c = name + data
return (
struct.pack(">I", len(data))
+ c
+ struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF)
)
signature = b"\x89PNG\r\n\x1a\n"
ihdr = chunk(b"IHDR", struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0))
raw_row = b"\x00\xff\x00\x00" # filter byte 0, R=255 G=0 B=0
idat = chunk(b"IDAT", zlib.compress(raw_row))
iend = chunk(b"IEND", b"")
return signature + ihdr + idat + iend
# ---------------------------------------------------------------------------
# Build the image attachment
# ---------------------------------------------------------------------------
def build_attachment() -> tuple[Attachment, str]:
"""Return (Attachment, file_name)."""
if SAMPLE_IMAGE_PATH and os.path.isfile(SAMPLE_IMAGE_PATH):
file_name = os.path.basename(SAMPLE_IMAGE_PATH)
return (
Attachment(
data=SAMPLE_IMAGE_PATH,
file_name=file_name,
content_type="image/png",
),
file_name,
)
# Fall back to the embedded 1×1 PNG
png_bytes = _make_sample_png_bytes()
file_name = "sample-image.png"
return (
Attachment(
data=png_bytes,
file_name=file_name,
content_type="image/png",
),
file_name,
)
# ---------------------------------------------------------------------------
# Create a single standalone trace (no thread)
# ---------------------------------------------------------------------------
def create_single_trace(client: opik.Opik) -> str:
"""Create one standalone trace (no thread_id) carrying the image attachment.
This is the artifact for verifying the trace-level LLM-as-judge attachment
routing: when the trace has attachments and the toggle is on, scoring switches
to the agentic-tools path so the judge can load the media via get_attachment,
and the {{trace}} variable lists the attachment.
"""
trace_id = id_helpers.generate_id()
attachment, image_file_name = build_attachment()
client.trace(
id=trace_id,
name="single-trace-vision-question",
project_name=PROJECT_NAME,
end_time=_now(),
input={
"role": "user",
"content": (
f"I've attached an image ({image_file_name}). "
"Can you describe what you see and identify any anomalies?"
),
},
output={
"role": "assistant",
"content": (
"I can see an image. It appears to contain a solid red pixel. "
"I notice it is an extremely small (1×1) image."
),
},
tags=["image", "vision", "single-trace"],
attachments=[attachment],
)
return trace_id
# ---------------------------------------------------------------------------
# Create the thread
# ---------------------------------------------------------------------------
def create_thread(client: opik.Opik) -> str:
thread_id = str(uuid.uuid4())
attachment, image_file_name = build_attachment()
# ------------------------------------------------------------------
# Turn 1 — user sends an image and asks a question
# ------------------------------------------------------------------
turn1_id = id_helpers.generate_id()
client.trace(
id=turn1_id,
name="turn-1-user-question",
thread_id=thread_id,
project_name=PROJECT_NAME,
end_time=_now(),
input={
"role": "user",
"content": (
f"I've attached an image ({image_file_name}). "
"Can you describe what you see and identify any anomalies?"
),
},
output={
"role": "assistant",
"content": (
"I can see an image. It appears to contain a solid red pixel. "
"I notice it is an extremely small (1×1) image — could you confirm "
"whether this is intentional or a rendering issue?"
),
},
tags=["image", "vision", "turn-1"],
attachments=[attachment],
)
# ------------------------------------------------------------------
# Turn 2 — assistant asks a follow-up (no attachment needed here)
# ------------------------------------------------------------------
turn2_id = id_helpers.generate_id()
client.trace(
id=turn2_id,
name="turn-2-clarification",
thread_id=thread_id,
project_name=PROJECT_NAME,
end_time=_now(),
input={
"role": "user",
"content": "It is intentional — it's a test image.",
},
output={
"role": "assistant",
"content": (
"Understood. The image is a 1×1 PNG with a single red (#FF0000) pixel. "
"No anomalies detected. Is there anything specific you'd like me to "
"analyse about the colour or format?"
),
},
tags=["vision", "turn-2"],
)
# ------------------------------------------------------------------
# Turn 3 — user wraps up; assistant gives a final summary
# ------------------------------------------------------------------
turn3_id = id_helpers.generate_id()
client.trace(
id=turn3_id,
name="turn-3-summary",
thread_id=thread_id,
project_name=PROJECT_NAME,
end_time=_now(),
input={
"role": "user",
"content": "No, that covers it. Thanks!",
},
output={
"role": "assistant",
"content": (
"You're welcome! To summarise: the image is a minimal 1×1 PNG containing "
"a pure red pixel with no anomalies. Let me know if you have more images "
"to analyse."
),
},
tags=["vision", "turn-3"],
)
return thread_id
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
import argparse
parser = argparse.ArgumentParser(description=__doc__)
group = parser.add_mutually_exclusive_group()
group.add_argument(
"--single", action="store_true", help="Create only the standalone single trace."
)
group.add_argument(
"--thread", action="store_true", help="Create only the multi-turn thread."
)
args = parser.parse_args()
do_single = args.single or not args.thread
do_thread = args.thread or not args.single
client = opik.Opik(project_name=PROJECT_NAME)
single_trace_id = None
thread_id = None
if do_single:
print(f"Creating single trace in project '{PROJECT_NAME}' ...")
single_trace_id = create_single_trace(client)
if do_thread:
print(f"Creating thread in project '{PROJECT_NAME}' ...")
thread_id = create_thread(client)
client.flush()
print(f"\nCreated successfully in project '{PROJECT_NAME}'.")
if single_trace_id is not None:
print(f" single trace_id : {single_trace_id}")
if thread_id is not None:
print(f" thread_id : {thread_id}")
print()
print(
"To verify the trace-level change, create a TRACE-level LLM-as-judge rule that"
)
print("references {{trace}} (map a variable to the bare string 'trace'), using a")
print("vision-capable, tool-calling model, with the agentic-tools toggle enabled.")
print(
"Scoring should switch to the agentic-tools path, read(type=trace) should list"
)
print("the attachment, and get_attachment should load it before a score is stored.")
if __name__ == "__main__":
main()
@@ -0,0 +1,37 @@
import threading
from opik import track, flush_tracker
from opik import opik_context
@track()
def f3(x, thread_name):
# creates trace1 with span2_1 in thread 1
# creates trace2 with span2_2 in thread 2
print(f"Done f3 from {thread_name}")
opik_context.update_current_span(tags=[f"f3-thread-{thread_name}"])
return f"f3 output from {thread_name}"
@track()
def f2(x):
# creates span 1 attached to trace 0 and parent span0
t1 = threading.Thread(target=f3, args=("f3-input-1", "thread-1"))
t2 = threading.Thread(target=f3, args=("f3-input-2", "thread-2"))
t1.start()
t1.join()
t2.start()
t2.join()
print("Done f2")
return "f2 output"
@track()
def f1(x):
# creates trace 0 with span 0
f2("f2 input")
print("Done f1")
return "f1 output"
f1("f1 input")
flush_tracker()
@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""
Trajectory Accuracy Evaluation Example
This example demonstrates how to use Opik's TrajectoryAccuracy metric
to evaluate ReAct-style agent trajectories within the evaluation framework.
"""
from typing import Dict, Any
from opik.evaluation.metrics import TrajectoryAccuracy
from opik.evaluation import evaluate
from opik import Opik, track
import json
def create_trajectory_dataset():
"""Create a dataset with ReAct-style trajectories for evaluation."""
client = Opik()
dataset = client.get_or_create_dataset(
name="trajectory_evaluation_dataset",
description="Dataset for evaluating ReAct-style agent trajectories",
)
# Sample trajectory data
trajectory_data = [
{
"trajectory_input": {
"goal": "Find the weather in Paris",
"trajectory": [
{
"thought": "I need to search for weather information in Paris",
"action": "search_weather(location='Paris')",
"observation": "Found weather data for Paris: 22°C, sunny",
},
{
"thought": "I have the weather data, now I should summarize it",
"action": "summarize_result()",
"observation": "Summary created: The weather in Paris is 22°C and sunny",
},
],
"final_result": "The weather in Paris is 22°C and sunny",
}
},
{
"trajectory_input": {
"goal": "Calculate the sum of 15 and 27",
"trajectory": [
{
"thought": "I need to add 15 and 27 together",
"action": "calculate(15 + 27)",
"observation": "Result: 42",
}
],
"final_result": "The sum of 15 and 27 is 42",
}
},
{
"trajectory_input": {
"goal": "Find the capital of France",
"trajectory": [
{
"thought": "I need to find France's capital",
"action": "search('weather in France')", # Poor action choice
"observation": "Found weather information for various French cities",
},
{
"thought": "This doesn't help, let me try something else",
"action": "search('French cuisine')", # Still poor choice
"observation": "Found information about French food",
},
],
"final_result": "Paris is the capital of France", # Result doesn't match trajectory
}
},
{
"trajectory_input": {
"goal": "Research the population of Tokyo",
"trajectory": [
{
"thought": "I need to search for Tokyo population data",
"action": "search('Tokyo population 2024')",
"observation": "Tokyo has approximately 14 million people in the city, 37 million in metro area",
},
{
"thought": "I found the data, let me verify with another source",
"action": "search('Tokyo metropolitan area population')",
"observation": "Confirmed: Tokyo metro area has about 37-38 million residents",
},
{
"thought": "Now I should summarize this information clearly",
"action": "summarize_findings()",
"observation": "Summary prepared with population figures",
},
],
"final_result": "Tokyo city has about 14 million people, while the greater Tokyo metropolitan area has approximately 37-38 million residents, making it the world's largest urban agglomeration.",
}
},
]
# Insert data into dataset
dataset.insert_from_json(
json_array=json.dumps(trajectory_data),
keys_mapping={"trajectory_input": "input"},
)
return dataset
@track()
def trajectory_evaluation_task(item: Dict[str, Any]) -> Dict[str, Any]:
"""
Task that simulates evaluating an agent trajectory.
In practice, this would be where your agent generates the trajectory.
"""
# Extract the trajectory components
trajectory_data = item["input"]
# For this example, we're just passing through the pre-made trajectory
# In a real scenario, this is where your agent would generate the trajectory
return {
"goal": trajectory_data["goal"],
"trajectory": trajectory_data["trajectory"],
"final_result": trajectory_data["final_result"],
"metadata": {
"trajectory_steps": len(trajectory_data["trajectory"]),
"evaluation_type": "react_agent_trajectory",
},
}
def main():
"""Run the trajectory accuracy evaluation example."""
print("🚀 Starting Trajectory Accuracy Evaluation with Opik")
print("=" * 60)
# Create dataset
print("📊 Creating trajectory dataset...")
dataset = create_trajectory_dataset()
print(f"✅ Dataset '{dataset.name}' created with trajectory examples")
# Create trajectory accuracy metric
trajectory_metric = TrajectoryAccuracy(
name="trajectory_accuracy_evaluation", track=True
)
print("\n🎯 Running evaluation...")
# Run evaluation
evaluation_result = evaluate(
experiment_name="trajectory_accuracy_experiment",
dataset=dataset,
task=trajectory_evaluation_task,
scoring_metrics=[trajectory_metric],
experiment_config={
"model": "gpt-4o-mini", # Following user rules
"evaluation_type": "react_agent_trajectory",
"metric": "trajectory_accuracy",
},
)
print("\n✅ Evaluation completed!")
print(f"📊 Experiment: {evaluation_result.experiment_name}")
print("📈 Results available in Opik dashboard")
# Display summary
print("\n📋 Summary:")
print(f" Total test cases: {len(evaluation_result.test_results)}")
print(" Metric used: TrajectoryAccuracy")
print(
" Evaluation assesses: reasoning quality, action appropriateness, goal achievement"
)
return evaluation_result
if __name__ == "__main__":
try:
result = main()
print("\n🎉 Trajectory Accuracy evaluation completed successfully!")
print("📊 View detailed results in your Opik dashboard")
except Exception as e:
print(f"\n❌ Evaluation failed: {e}")
print("💡 Make sure you have:")
print(" - OPENAI_API_KEY set in environment")
print(" - Opik properly configured")
print(" - Network connectivity for LLM calls")
@@ -0,0 +1,223 @@
#!/usr/bin/env python3
"""
Example script for the TrajectoryAccuracy metric.
This script demonstrates how to use the TrajectoryAccuracy metric
with sample ReAct-style agent trajectories.
"""
import sys
import os
from opik.evaluation.metrics import TrajectoryAccuracy
# Add the parent directory to the Python path to ensure the 'opik' module can be found.
sys.path.insert(
0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
)
def run_basic_example(metric: TrajectoryAccuracy):
"""Demonstrates the TrajectoryAccuracy metric with a basic example."""
print("Running TrajectoryAccuracy with a basic example...")
print("=" * 60)
example = {
"goal": "Find the weather in Paris",
"trajectory": [
{
"thought": "I need to search for weather information in Paris",
"action": "search_weather(location='Paris')",
"observation": "Found weather data for Paris: 22°C, sunny",
},
{
"thought": "I found the weather, now summarizing",
"action": "summarize_weather()",
"observation": "The weather in Paris is 22°C and sunny",
},
],
"final_result": "The weather in Paris is 22°C and sunny",
}
try:
result = metric.score(**example)
print("INPUT:")
print(f"Goal: {example['goal']}")
print(f"Number of trajectory steps: {len(example['trajectory'])}")
print(f"Final result: {example['final_result']}")
print()
print("OUTPUT:")
print(f"Score: {result.value}")
print(f"Explanation: {result.reason}")
print()
# Validate result format
assert isinstance(result.value, float), "Score should be a float"
assert 0.0 <= result.value <= 1.0, (
f"Score {result.value} should be between 0.0 and 1.0"
)
assert isinstance(result.reason, str), "Explanation should be a string"
assert len(result.reason) > 0, "Explanation should not be empty"
print("✅ Example completed successfully!")
return True
except Exception as e:
print(f"❌ Example failed with error: {e}")
return False
def run_edge_cases_example(metric: TrajectoryAccuracy):
"""Demonstrates the TrajectoryAccuracy metric with various edge cases."""
print("\nRunning edge cases...")
print("=" * 60)
test_cases = [
{
"name": "Empty trajectory",
"example": {
"goal": "Do something",
"trajectory": [],
"final_result": "Nothing was done",
},
},
{
"name": "Missing goal",
"example": {
"goal": "",
"trajectory": [
{
"thought": "I need to do something",
"action": "do_action()",
"observation": "Action completed",
}
],
"final_result": "Task completed",
},
},
{
"name": "Incomplete trajectory step",
"example": {
"goal": "Find information",
"trajectory": [
{
"thought": "I need to search",
}
],
"final_result": "Search completed",
},
},
]
passed_count = 0
for case in test_cases:
print(f"\nRunning case: {case['name']}")
try:
result = metric.score(**case["example"])
print(f" Score: {result.value}")
print(f" Explanation: {result.reason[:100]}...")
# Basic validation
assert isinstance(result.value, float)
assert 0.0 <= result.value <= 1.0
assert isinstance(result.reason, str)
print(" ✅ Passed")
passed_count += 1
except Exception as e:
print(f" ❌ Failed: {e}")
print(f"\nEdge case examples: {passed_count}/{len(test_cases)} completed")
return passed_count == len(test_cases)
def run_complex_trajectory_example(metric: TrajectoryAccuracy):
"""Demonstrates the metric with a more complex multi-step trajectory."""
print("\nRunning complex trajectory example...")
print("=" * 60)
example = {
"goal": "Research and summarize the population of the top 3 largest cities in France",
"trajectory": [
{
"thought": "I need to find information about the largest cities in France first",
"action": "search(query='largest cities in France')",
"observation": "Found that Paris, Marseille, and Lyon are the top 3 largest cities",
},
{
"thought": "Now I need to get population data for Paris",
"action": "search(query='Paris France population 2024')",
"observation": "Paris population is approximately 2.16 million",
},
{
"thought": "Next, I need population data for Marseille",
"action": "search(query='Marseille France population 2024')",
"observation": "Marseille population is approximately 870,000",
},
{
"thought": "Finally, I need population data for Lyon",
"action": "search(query='Lyon France population 2024')",
"observation": "Lyon population is approximately 520,000",
},
{
"thought": "Now I have all the data, I should summarize it",
"action": "summarize(data='Paris: 2.16M, Marseille: 870K, Lyon: 520K')",
"observation": "Summary created with population data for top 3 French cities",
},
],
"final_result": "The top 3 largest cities in France by population are: 1) Paris (2.16 million), 2) Marseille (870,000), 3) Lyon (520,000)",
}
try:
result = metric.score(**example)
print("COMPLEX TRAJECTORY EXAMPLE:")
print(f"Goal: {example['goal']}")
print(f"Steps: {len(example['trajectory'])}")
print(f"Score: {result.value}")
print(f"Explanation: {result.reason}")
assert isinstance(result.value, float)
assert 0.0 <= result.value <= 1.0
assert isinstance(result.reason, str)
print("✅ Complex trajectory example completed!")
return True
except Exception as e:
print(f"❌ Complex trajectory example failed: {e}")
return False
if __name__ == "__main__":
print("Trajectory Accuracy Metric Example Suite")
print("=" * 60)
# Instantiate the metric
trajectory_metric = TrajectoryAccuracy()
# Run all examples
success_count = 0
total_examples = 3
if run_basic_example(trajectory_metric):
success_count += 1
if run_edge_cases_example(trajectory_metric):
success_count += 1
if run_complex_trajectory_example(trajectory_metric):
success_count += 1
print("\n" + "=" * 60)
print(f"FINAL RESULTS: {success_count}/{total_examples} example suites ran")
if success_count == total_examples:
print("🎉 All examples ran successfully!")
sys.exit(0)
else:
print("⚠️ Some examples failed. Please check the output above.")
sys.exit(1)
+10
View File
@@ -0,0 +1,10 @@
[tool.mypy]
follow_imports = "skip"
ignore_missing_imports = true
disallow_untyped_defs = true
disallow_untyped_calls = true
check_untyped_defs = true
python_version = "3.10"
[tool.uv]
managed = false
+109
View File
@@ -0,0 +1,109 @@
import os
from setuptools import find_packages, setup
project_urls = {"Source code": "https://github.com/comet-ml/opik"}
HERE = os.path.abspath(os.path.dirname(__file__))
version = os.environ.get("VERSION")
if version is None:
version_file = os.path.join(HERE, "..", "..", "version.txt")
if os.path.exists(version_file):
with open(version_file) as fp:
version = fp.read().strip()
else:
version = "0.0.1"
setup(
author="Comet ML Inc.",
author_email="mail@comet.com",
python_requires=">=3.10",
classifiers=[
"Development Status :: 2 - Pre-Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Natural Language :: English",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
],
description="Comet tool for logging and evaluating LLM traces",
long_description=open(
os.path.join(HERE, "..", "..", "README.md"), encoding="utf-8"
).read(),
long_description_content_type="text/markdown",
install_requires=[
"boto3-stubs[bedrock-runtime]>=1.34.110",
"click",
"httpx", # some older version of openai/litellm are broken with httpx>=0.28.0
"rapidfuzz>=3.0.0,<4.0.0",
# LiteLLM dependency comments:
# Please keep this list in sync with the one in sdks/opik_optimizer/pyproject.toml
# - Exclude 1.82.7, 1.82.8: compromised in supply chain attack (TeamPCP)
# See: https://docs.litellm.ai/blog/security-update-march-2026
# - Exclude 1.81.*, 1.82.*, 1.83.0-1.83.6: CVE-2026-42208 (SQL injection in proxy auth path,
# affects 1.81.16-1.83.6, fixed in 1.83.7).
# See: https://docs.litellm.ai/blog/cve-2026-42208-litellm-proxy-sql-injection
"litellm>=1.79.2,!=1.81.*,!=1.82.*,!=1.83.0,!=1.83.1,!=1.83.2,!=1.83.3,!=1.83.4,!=1.83.5,!=1.83.6",
"openai",
"pydantic-settings>=2.0.0,<3.0.0,!=2.9.0",
"pydantic>=2.0.0,<3.0.0",
"pytest",
"rich",
"sentry_sdk>=2.0.0",
"tenacity",
"tqdm",
"uuid6",
"jinja2",
"watchfiles>=1.0.0,<2.0.0",
# tree-sitter is used for JS/TS syntax checking in bridge handlers.
# Pre-built wheels are missing for musllinux_aarch64 (Alpine on ARM64),
# and PEP 508 has no marker to distinguish musl from glibc, so we
# exclude all Linux aarch64 to avoid a source-build failure on Alpine.
# Affected glibc aarch64 users can manually:
# pip install tree-sitter tree-sitter-javascript \
# tree-sitter-typescript
(
"tree-sitter>=0.23,<1.0;"
" platform_machine != 'aarch64'"
" or sys_platform != 'linux'"
),
(
"tree-sitter-javascript>=0.23,<1.0;"
" platform_machine != 'aarch64'"
" or sys_platform != 'linux'"
),
(
"tree-sitter-typescript>=0.23,<1.0;"
" platform_machine != 'aarch64'"
" or sys_platform != 'linux'"
),
],
extras_require={
"proxy": [
"fastapi>=0.100.0",
"uvicorn>=0.23.0",
],
},
entry_points={
"pytest11": [
"opik = opik.plugins.pytest.hooks",
],
"console_scripts": ["opik = opik.cli:cli"],
},
keywords="opik",
name="opik",
include_package_data=True,
package_data={"opik": ["py.typed"]},
packages=find_packages("src"),
package_dir={"": "src"},
url="https://www.comet.com",
project_urls=project_urls,
version=version,
zip_safe=False,
license="Apache 2.0 License",
)
+14
View File
@@ -0,0 +1,14 @@
"""Lightweight entrypoint for core Opik metric types.
Provides :class:`BaseMetric` and :class:`ScoreResult` without pulling in
the full ``opik`` package, keeping import time near zero.
Usage::
from _opik import BaseMetric, ScoreResult
"""
from ._score_result import ScoreResult
from ._base_metric import BaseMetric
__all__ = ["BaseMetric", "ScoreResult"]
+43
View File
@@ -0,0 +1,43 @@
"""Lightweight BaseMetric ABC with no heavy dependencies."""
import abc
from typing import Any, List, Optional, Union
from . import _score_result
class BaseMetric(abc.ABC):
"""Abstract base class for all metrics.
Subclass this and implement :meth:`score` to create a custom metric.
The lightweight version carries no tracking or configuration overhead,
making it suitable for contexts that only need the metric interface.
Args:
name: Display name for the metric. Defaults to the class name.
track: Whether the metric should be tracked by Opik.
project_name: Optional project to associate tracked results with.
"""
def __init__(
self,
name: Optional[str] = None,
track: bool = True,
project_name: Optional[str] = None,
) -> None:
self.name = name if name is not None else self.__class__.__name__
self.track = track
self.project_name = project_name
@abc.abstractmethod
def score(
self, *args: Any, **kwargs: Any
) -> Union[_score_result.ScoreResult, List[_score_result.ScoreResult]]:
"""Compute the metric score. Must be implemented by subclasses."""
raise NotImplementedError()
async def ascore(
self, *args: Any, **kwargs: Any
) -> Union[_score_result.ScoreResult, List[_score_result.ScoreResult]]:
"""Async variant of :meth:`score`. Defaults to calling ``score``."""
return self.score(*args, **kwargs)
+25
View File
@@ -0,0 +1,25 @@
"""Lightweight ScoreResult dataclass with no heavy dependencies."""
import dataclasses
from typing import Any, Dict, Optional
@dataclasses.dataclass
class ScoreResult:
"""Result returned by a metric's ``score`` method.
Attributes:
name: Metric name that produced this result.
value: Numeric score value.
reason: Optional human-readable explanation.
category_name: Optional category label.
metadata: Optional dictionary of extra metadata.
scoring_failed: Flag indicating the scoring could not complete.
"""
name: str
value: float
reason: Optional[str] = None
category_name: Optional[str] = None
metadata: Optional[Dict[str, Any]] = None
scoring_failed: bool = False
+114
View File
@@ -0,0 +1,114 @@
from . import _logging, environment, error_tracking, package_version
from .api_objects.annotation_queue import (
TracesAnnotationQueue,
ThreadsAnnotationQueue,
)
from .api_objects.attachment import Attachment
from .rest_api.types.environment_public import EnvironmentPublic as Environment
from .api_objects import dashboard
from .api_objects.dashboard import Dashboard
from .api_objects.dataset import Dataset
from .api_objects.dataset.test_suite import TestSuite
from .api_objects.dataset.test_suite.types import TestSuiteResult
from .api_objects.experiment.experiment_item import (
ExperimentItemContent,
ExperimentItemReferences,
)
from .api_objects.agent_config import Config
from .api_objects.agent_config.context import agent_config_context
from .exceptions import ConfigNotFound, ConfigMismatch, DashboardValidationError
from .api_objects.opik_client import Opik, get_global_client, set_global_client
from .api_objects.prompt import Prompt, ChatPrompt
from .api_objects.prompt.types import PromptType
from .api_objects.span import Span
from .api_objects.trace import Trace
from .configurator.configure import configure
from .decorator.tracker import flush_tracker, track
from .evaluation import (
evaluate,
evaluate_experiment,
evaluate_on_dict_items,
evaluate_prompt,
evaluate_resume,
run_tests,
)
from .integrations.sagemaker import auth as sagemaker_auth
from .plugins.pytest.decorator import llm_unit
from .types import LLMProvider
from . import opik_context
from .tracing_runtime_config import (
is_tracing_active,
reset_tracing_to_config_default,
set_tracing_active,
)
from .decorator.context_manager.span_context_manager import start_as_current_span
from .decorator.context_manager.trace_context_manager import start_as_current_trace
from .simulation import SimulatedUser, run_simulation
from .api_objects.local_recording import record_traces_locally
from .context_storage import project_context
from .opik_context import update_current_trace, update_current_span
_logging.setup()
__version__ = package_version.VERSION
__all__ = [
"__version__",
"TracesAnnotationQueue",
"ThreadsAnnotationQueue",
"Attachment",
"Environment",
"evaluate",
"evaluate_prompt",
"evaluate_experiment",
"evaluate_on_dict_items",
"evaluate_resume",
"run_tests",
"ExperimentItemContent",
"ExperimentItemReferences",
"track",
"flush_tracker",
"Opik",
"get_global_client",
"set_global_client",
"opik_context",
"Trace",
"Span",
"dashboard",
"Dashboard",
"Dataset",
"TestSuite",
"TestSuiteResult",
"llm_unit",
"configure",
"Prompt",
"ChatPrompt",
"PromptType",
"LLMProvider",
"reset_tracing_to_config_default",
"set_tracing_active",
"is_tracing_active",
"start_as_current_span",
"start_as_current_trace",
"SimulatedUser",
"run_simulation",
"record_traces_locally",
"Config",
"ConfigNotFound",
"ConfigMismatch",
"DashboardValidationError",
"agent_config_context",
"update_current_trace",
"update_current_span",
"project_context",
]
sagemaker_auth.setup_aws_sagemaker_session_hook()
if (
error_tracking.enabled_in_config()
and not environment.in_pytest()
and error_tracking.randomized_should_enable_reporting()
):
error_tracking.setup_sentry_error_tracker()
+104
View File
@@ -0,0 +1,104 @@
import functools
import logging
from typing import Any, Callable, Optional, Set
from . import config
CONSOLE_MSG_FORMAT = "OPIK: %(message)s"
DEBUG_MSG_FORMAT = "%(asctime)s [%(process)d-%(processName)s:%(thread)d] %(relativeCreated)d OPIK %(levelname)s [%(filename)s:%(lineno)d]: %(message)s"
# 1MB, to prevent logger from frequent writing hundreds of megabytes in DEBUG mode
# when batches are big and payloads are heavy (e.g. base64 encoded data)
MAX_MESSAGE_LENGTH = 1024 * 1024
LOG_ONCE_CACHE: Set[str] = set()
class TruncateFormatter(logging.Formatter):
def __init__(
self,
fmt: str,
datefmt: Optional[str] = None,
max_length: int = MAX_MESSAGE_LENGTH,
) -> None:
super().__init__(fmt, datefmt)
self.max_length = max_length
def format(self, record: logging.LogRecord) -> str:
result = super().format(record)
if len(result) > self.max_length:
result = result[: self.max_length] + "... (truncated)."
return result
def setup() -> None:
opik_root_logger = logging.getLogger("opik")
opik_root_logger.propagate = False
config_ = config.OpikConfig()
console_handler = logging.StreamHandler()
console_level = config_.console_logging_level
console_handler.setLevel(console_level)
message_format = (
DEBUG_MSG_FORMAT if console_level == "DEBUG" else CONSOLE_MSG_FORMAT
)
console_handler.setFormatter(TruncateFormatter(message_format))
opik_root_logger.addHandler(console_handler)
root_level = console_handler.level
if config_.file_logging_level is not None:
file_handler = logging.FileHandler(config_.logging_file)
file_level = config_.file_logging_level
file_handler.setLevel(file_level)
file_handler.setFormatter(TruncateFormatter(DEBUG_MSG_FORMAT))
opik_root_logger.addHandler(file_handler)
root_level = min(root_level, file_handler.level)
opik_root_logger.setLevel(level=root_level)
def convert_exception_to_log_message(
message: str,
logger: logging.Logger,
return_on_exception: Any = None,
logging_level: int = logging.ERROR,
**log_kwargs: Any,
) -> Callable:
def decorator(function: Callable) -> Any:
@functools.wraps(function)
def wrapper(*args: Any, **kwargs: Any) -> Any:
try:
return function(*args, **kwargs)
except Exception:
logger.log(logging_level, message, **log_kwargs)
return return_on_exception
return wrapper
return decorator
def log_once_at_level(
logging_level: int,
message: str,
logger: logging.Logger,
*args: Any,
**kwargs: Any,
) -> None:
"""
Log the given message once at the given level then at the DEBUG
level on further calls.
This is a global log-once-per-session
"""
global LOG_ONCE_CACHE
if message not in LOG_ONCE_CACHE:
LOG_ONCE_CACHE.add(message)
logger.log(logging_level, message, *args, **kwargs)
else:
logger.debug(message, *args, **kwargs)
@@ -0,0 +1,5 @@
from .anonymizer import Anonymizer
from .factory import create_anonymizer
from .recursive_anonymizer import RecursiveAnonymizer
__all__ = ["Anonymizer", "create_anonymizer", "RecursiveAnonymizer"]
@@ -0,0 +1,12 @@
import abc
from typing import Dict, Any, Union, List
AnonymizerDataType = Union[Dict[str, Any], str, List[Any]]
class Anonymizer(abc.ABC):
"""Abstract base class for anonymizing sensitive data in various data structures."""
@abc.abstractmethod
def anonymize(self, data: AnonymizerDataType, **kwargs: Any) -> AnonymizerDataType:
pass
@@ -0,0 +1,80 @@
from typing import Union, List, Dict, Callable, Tuple
from . import anonymizer, rules_anonymizer, rules
RulesType = Union[
List[Dict[str, str]],
List[Tuple[str, str]],
List[Callable[[str], str]],
List[Union[Dict[str, str], Tuple[str, str], Callable[[str], str]]],
Dict[str, str],
Tuple[str, str],
Callable[[str], str],
]
def create_anonymizer(
anonymizer_rules: RulesType, max_depth: int = 10
) -> anonymizer.Anonymizer:
"""Create an anonymizer with the specified rules.
Args:
anonymizer_rules: Anonymizer rules specification in various formats:
- Dict with "regex" and "replace" keys for a single regex rule
- Tuple with (regex, replacement) for a single regex rule
- Callable that takes a string and returns anonymized string
- List of any of the above for multiple rules
max_depth: Maximum recursion depth for nested data structures.
Returns:
An Anonymizer instance configured with the specified rules.
Raises:
ValueError: If a rule format is invalid.
"""
rule_objects: List[rules.Rule] = []
if callable(anonymizer_rules):
# Single function rule
rule_objects.append(rules.FunctionRule(anonymizer_rules))
elif isinstance(anonymizer_rules, dict):
# Single dictionary rule
_check_dictionary_rule(anonymizer_rules)
rule_objects.append(
rules.RegexRule(anonymizer_rules["regex"], anonymizer_rules["replace"])
)
elif isinstance(anonymizer_rules, tuple):
# Single tuple rule
_check_tuple_rule(anonymizer_rules)
regex_pattern, replacement = anonymizer_rules
rule_objects.append(rules.RegexRule(regex_pattern, replacement))
elif isinstance(anonymizer_rules, list):
# List of rules
for rule in anonymizer_rules:
if callable(rule) and not isinstance(rule, (dict, tuple)):
rule_objects.append(rules.FunctionRule(rule))
elif isinstance(rule, dict):
_check_dictionary_rule(rule)
rule_objects.append(rules.RegexRule(rule["regex"], rule["replace"]))
elif isinstance(rule, tuple):
_check_tuple_rule(rule)
regex_pattern, replacement = rule
rule_objects.append(rules.RegexRule(regex_pattern, replacement))
else:
raise ValueError(f"Unsupported rule type in list: {type(rule)}")
else:
raise ValueError(f"Unsupported rules type: {type(anonymizer_rules)}")
return rules_anonymizer.RulesAnonymizer(rule_objects, max_depth=max_depth)
def _check_dictionary_rule(rule: Dict[str, str]) -> None:
if "regex" not in rule or "replace" not in rule:
raise ValueError("Dictionary rule must have 'regex' and 'replace' keys")
def _check_tuple_rule(rule: Tuple[str, str]) -> None:
if len(rule) != 2:
raise ValueError(
"Tuple rule must have exactly 2 elements: (regex, replacement)"
)
@@ -0,0 +1,64 @@
import abc
from typing import Any, Optional
from . import anonymizer
class RecursiveAnonymizer(anonymizer.Anonymizer):
"""Abstract base class for anonymizing sensitive data in various data structures.
This class provides a framework for recursively anonymizing text content within
nested data structures such as dictionaries, lists, and strings. Subclasses must
implement the anonymize_text() method to define the specific anonymization logic.
"""
def __init__(self, max_depth: int = 10):
"""Initialize the Anonymizer with depth limiting.
Args:
max_depth: Maximum recursion depth to prevent infinite loops when
processing deeply nested or circular data structures.
Defaults to 10.
"""
self.max_depth = max_depth
def anonymize(
self, data: anonymizer.AnonymizerDataType, **kwargs: Any
) -> anonymizer.AnonymizerDataType:
return self._recursive_anonymize(data, **kwargs)
@abc.abstractmethod
def anonymize_text(self, data: str, **kwargs: Any) -> str:
pass
def _recursive_anonymize(
self,
data: anonymizer.AnonymizerDataType,
depth: int = 0,
field_name: Optional[str] = None,
**kwargs: Any,
) -> anonymizer.AnonymizerDataType:
if depth >= self.max_depth:
return data
if field_name is None:
field_name = ""
if isinstance(data, str):
return self.anonymize_text(data, field_name=field_name, **kwargs)
elif isinstance(data, dict):
return {
key: self._recursive_anonymize(
value, depth + 1, field_name=f"{field_name}.{key}", **kwargs
)
for key, value in data.items()
}
elif isinstance(data, list):
return [
self._recursive_anonymize(
item, depth + 1, field_name=f"{field_name}.{i}", **kwargs
)
for i, item in enumerate(data)
]
else:
return data
+56
View File
@@ -0,0 +1,56 @@
import abc
import re
from typing import Callable
class Rule(abc.ABC):
"""Abstract base class for text anonymization rules.
Rules define specific patterns or conditions for anonymizing sensitive
information in text. Subclasses must implement the apply() method to
define the anonymization logic.
"""
@abc.abstractmethod
def apply(self, text: str) -> str:
pass
class RegexRule(Rule):
"""A rule that uses regular expressions to find and replace patterns in text.
This rule compiles a regular expression pattern and applies it to input text,
replacing all matches with a specified replacement string.
"""
def __init__(self, regex: str, replacement: str):
"""Initialize the regex rule with a pattern and replacement.
Args:
regex: Regular expression pattern to match sensitive data.
replacement: String to replace matched patterns with.
"""
self.pattern = re.compile(regex)
self.replacement = replacement
def apply(self, text: str) -> str:
return self.pattern.sub(self.replacement, text)
class FunctionRule(Rule):
"""A rule that applies a custom function to anonymize text.
This rule allows for flexible anonymization by accepting any callable
that takes a string as input and returns an anonymized string.
"""
def __init__(self, func: Callable[[str], str]):
"""Initialize the function rule with a custom anonymization function.
Args:
func: A callable that takes a string and returns an anonymized version.
"""
self.func = func
def apply(self, text: str) -> str:
return self.func(text)
@@ -0,0 +1,35 @@
from typing import List, Any
from . import recursive_anonymizer, rules
class RulesAnonymizer(recursive_anonymizer.RecursiveAnonymizer):
"""An anonymizer that applies a list of rules sequentially to text data.
This class takes a list of Rule objects and applies them to
anonymize sensitive information in text.
"""
def __init__(self, anonymizer_rules: List[rules.Rule], max_depth: int = 10):
"""Initialize the RulesAnonymizer with a list of rules.
Args:
anonymizer_rules: List of Rule objects to apply for anonymization.
max_depth: Maximum recursion depth for nested data structures.
"""
super().__init__(max_depth)
self.rules = anonymizer_rules
def anonymize_text(self, data: str, **kwargs: Any) -> str:
"""Apply all rules sequentially to the input text.
Args:
data: The text to anonymize.
Returns:
The anonymized text after applying all rules.
"""
result = data
for rule in self.rules:
result = rule.apply(result)
return result
+13
View File
@@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
# *******************************************************
# ____ _ _
# / ___|___ _ __ ___ ___| |_ _ __ ___ | |
# | | / _ \| '_ ` _ \ / _ \ __| | '_ ` _ \| |
# | |__| (_) | | | | | | __/ |_ _| | | | | | |
# \____\___/|_| |_| |_|\___|\__(_)_| |_| |_|_|
#
# Sign up for free at https://www.comet.com
# Copyright (C) 2015-2024 Comet ML INC
# This file can not be copied and/or distributed
# without the express permission of Comet ML Inc.
# *******************************************************
@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
# *******************************************************
# ____ _ _
# / ___|___ _ __ ___ ___| |_ _ __ ___ | |
# | | / _ \| '_ ` _ \ / _ \ __| | '_ ` _ \| |
# | |__| (_) | | | | | | __/ |_ _| | | | | | |
# \____\___/|_| |_| |_|\___|\__(_)_| |_| |_|_|
#
# Sign up for free at https://www.comet.com
# Copyright (C) 2015-2024 Comet ML INC
# This file can not be copied and/or distributed
# without the express permission of Comet ML Inc.
# *******************************************************
import base64
def decode_base64(data: str, fix_padding: bool = True) -> bytes:
if fix_padding:
missing_padding = len(data) % 4
if missing_padding and data.endswith("="):
# wrong padding
data = data.replace("=", "")
return decode_base64(data)
if missing_padding:
data += "=" * (4 - missing_padding)
return base64.b64decode(data, validate=True)
@@ -0,0 +1,97 @@
# -*- coding: utf-8 -*-
# *******************************************************
# ____ _ _
# / ___|___ _ __ ___ ___| |_ _ __ ___ | |
# | | / _ \| '_ ` _ \ / _ \ __| | '_ ` _ \| |
# | |__| (_) | | | | | | __/ |_ _| | | | | | |
# \____\___/|_| |_| |_|\___|\__(_)_| |_| |_|_|
#
# Sign up for free at https://www.comet.com
# Copyright (C) 2015-2024 Comet ML INC
# This file can not be copied and/or distributed
# without the express permission of Comet ML Inc.
# *******************************************************
import json
import logging
from typing import Any, Dict, Optional
from ..logging_messages import (
PARSE_API_KEY_EMPTY_EXPECTED_ATTRIBUTES,
PARSE_API_KEY_EMPTY_KEY,
PARSE_API_KEY_TOO_MANY_PARTS,
)
from .base64_helper import decode_base64
LOGGER = logging.getLogger(__name__)
DELIMITER_CHAR = "*"
class OpikApiKey:
"""
This is Opik API key parser module which is able to parse enhanced API key format. The format as following:
initial 25 chars apiKey + DELIMITER_CHAR + base64 encoded OPIK_BASE_URL and other attributes as JSON dictionary.
The logic of this module is shared among comet_ml, comet_mpm, and opik projects.
Please do not change this module without synchronization with mentioned projects.
"""
def __init__(
self,
api_key_raw: str,
api_key: Optional[str] = None,
attributes: Optional[Dict[str, Any]] = None,
):
self._api_key_raw = api_key_raw
self._api_key = api_key
self._attributes = attributes
@property
def api_key(self) -> Optional[str]:
return self._api_key_raw
@property
def short_api_key(self) -> Optional[str]:
if self._api_key is not None:
return self._api_key
return self._api_key_raw
@property
def base_url(self) -> Optional[str]:
if self["baseUrl"] is not None:
return str(self["baseUrl"])
else:
return None
def __getitem__(self, key: str) -> Any:
if self._attributes is not None:
return self._attributes.get(key, None)
return None
def parse_api_key(raw_key: str) -> Optional[OpikApiKey]:
if raw_key is None or len(raw_key) == 0:
LOGGER.debug(PARSE_API_KEY_EMPTY_KEY)
return None
parts = raw_key.split(DELIMITER_CHAR)
size = len(parts)
if size == 1:
LOGGER.debug("Opik API key doesn't have attributes associated")
return OpikApiKey(api_key_raw=raw_key)
elif size == 2:
attr_string = parts[1]
if len(attr_string) > 0:
data = decode_base64(attr_string)
attributes = json.loads(data)
else:
# edge case - delimiter found but no encoded JSON afterward
LOGGER.warning(PARSE_API_KEY_EMPTY_EXPECTED_ATTRIBUTES % raw_key)
raw_key = parts[0] # remove obsolete delimiter
attributes = None
return OpikApiKey(api_key_raw=raw_key, api_key=parts[0], attributes=attributes)
LOGGER.warning(PARSE_API_KEY_TOO_MANY_PARTS, size, raw_key)
return None
@@ -0,0 +1,11 @@
from .base import Config
from .cache import get_global_registry
from .config import ConfigManager
from .context import agent_config_context
__all__ = [
"Config",
"ConfigManager",
"get_global_registry",
"agent_config_context",
]
@@ -0,0 +1,565 @@
import dataclasses
import logging
import typing
from opik.exceptions import ConfigMismatch, ConfigNotFound
from opik.rest_api import core as rest_api_core
from .. import type_helpers
from . import cache as cache_mod, types
from .context import get_active_config_mask, get_active_config_blueprint_name
logger = logging.getLogger(__name__)
_MISSING = object()
T = typing.TypeVar("T", bound="Config")
@dataclasses.dataclass
class _OpikState:
project: typing.Optional[str] = None
env: typing.Optional[str] = None
mask_id: typing.Optional[str] = None
version: typing.Optional[str] = None
manager: typing.Any = None
blueprint_id: typing.Optional[str] = None
blueprint_version: typing.Optional[str] = None
is_fallback: bool = True
def _infer_python_type(value: typing.Any) -> typing.Any:
"""Return the Python type for a field value. ``None`` maps to ``str``."""
if value is None:
return str
return type(value)
def _require_track_context() -> None:
"""Raise RuntimeError unless called inside an @opik.track function."""
from opik import opik_context # avoid circular import
if (
opik_context.get_current_trace_data() is None
and opik_context.get_current_span_data() is None
):
raise RuntimeError(
"get_or_create_config() must be called inside a function decorated with "
"@opik.track. Call get_or_create_config() from within a @opik.track-decorated function."
)
def _apply_context_overrides(
version: typing.Optional[str],
) -> typing.Tuple[typing.Optional[str], typing.Optional[str]]:
"""Apply runner-supplied context overrides. Returns ``(version, mask_id)``."""
blueprint_name_override = get_active_config_blueprint_name()
if blueprint_name_override is not None:
version = blueprint_name_override
return version, get_active_config_mask()
def _fetch_by_selector(
manager: typing.Any,
*,
version: typing.Optional[str],
env: typing.Optional[str],
mask_id: typing.Optional[str],
field_types: typing.Dict[str, typing.Any],
timeout_in_seconds: typing.Optional[int],
) -> typing.Any:
"""Fetch a blueprint by version, env, or latest (in priority order)."""
if version is not None:
return manager.get_blueprint(
name=version,
mask_id=mask_id,
field_types=field_types,
timeout_in_seconds=timeout_in_seconds,
)
if env is not None:
return manager.get_blueprint(
env=env,
mask_id=mask_id,
field_types=field_types,
timeout_in_seconds=timeout_in_seconds,
)
return manager.get_blueprint(
mask_id=mask_id,
field_types=field_types,
timeout_in_seconds=timeout_in_seconds,
)
def _init_fallback_cache_entry(
project_name: str,
resolved_env: typing.Optional[str],
mask_id: typing.Optional[str],
field_types: typing.Dict[str, typing.Any],
manager: typing.Any,
version: typing.Optional[str],
) -> None:
"""Record a cache entry with no blueprint; subsequent reads will hit it as fallback."""
logger.debug("Failed to fetch config from backend, using fallback", exc_info=True)
cache_mod.init_cache_entry(
project_name,
resolved_env,
mask_id,
field_types,
manager,
version=version,
)
def _validate_prompt_project_names(
config: "Config",
project_name: str,
) -> None:
"""Raise ConfigMismatch if any Prompt/ChatPrompt field belongs to a different project."""
from opik.api_objects.prompt.base_prompt import BasePrompt # avoid circular import
mismatched = []
for name in type(config).__field_names__:
value = object.__getattribute__(config, name)
if isinstance(value, BasePrompt):
prompt_project = value.project_name
if prompt_project is not None and prompt_project != project_name:
mismatched.append((name, prompt_project))
if mismatched:
details = ", ".join(f"{name!r} (project={proj!r})" for name, proj in mismatched)
raise ConfigMismatch(
f"Config project is {project_name!r}, but the following prompt field(s) "
f"belong to a different project: {details}. "
f"All prompts referenced in a config must belong to the same project as the config."
)
def _all_prompts_synced(config: "Config") -> bool:
"""Return True only when every BasePrompt field has a non-None commit.
A None commit means the prompt has not been persisted to the backend yet.
Creating a blueprint with an unsynced prompt would store None as the prompt
reference, producing a broken config object.
"""
from opik.api_objects.prompt.base_prompt import BasePrompt # avoid circular import
for name in type(config).__field_names__:
value = object.__getattribute__(config, name)
if isinstance(value, BasePrompt) and value.commit is None:
logger.debug(
"Prompt field %r has no commit — it has not been persisted to the backend yet.",
name,
)
return False
return True
def _validate_blueprint_schema(cls: typing.Type["Config"], bp: typing.Any) -> None:
"""Raise ConfigMismatch if ``bp`` is missing any field declared on ``cls``."""
missing_keys = [name for name in cls.__field_names__ if name not in bp.keys()]
if missing_keys:
version_label = bp.name or bp.id or "unknown"
raise ConfigMismatch(
f"Config version {version_label!r} is missing expected field(s): "
f"{missing_keys}. The retrieved version does not contain all fields "
f"declared in {cls.__name__}."
)
def _build_live_instance(
cls: typing.Type[T],
bp: typing.Any,
*,
project_name: str,
resolved_env: typing.Optional[str],
mask_id: typing.Optional[str],
version: typing.Optional[str],
manager: typing.Any,
field_types: typing.Dict[str, typing.Any],
) -> T:
"""Construct a backend-backed Config instance and seed its cache entry."""
_validate_blueprint_schema(cls, bp)
kwargs: typing.Dict[str, typing.Any] = {
name: bp[name] for name in cls.__field_names__
}
instance = cls(**kwargs)
state = instance._state
state.project = project_name
state.env = resolved_env
state.mask_id = mask_id
state.version = version
state.manager = manager
state.blueprint_id = bp.id
state.blueprint_version = bp.name
state.is_fallback = False
cache_mod.init_cache_entry(
project_name,
resolved_env,
mask_id,
field_types,
manager,
blueprint=bp,
version=version,
)
return instance
def _missing_config_error(
project_name: str,
*,
env: typing.Optional[str],
version: typing.Optional[str],
) -> ConfigNotFound:
if version is not None:
return ConfigNotFound(
f"No config found for version={version!r} in project {project_name!r}."
)
return ConfigNotFound(
f"No config found for env={env!r} in project {project_name!r}."
)
class Config:
"""Base class for user-defined configurations.
Subclass this and declare the fields you want to publish. The annotations
are used **only to register field names** (so the class can be turned into
a dataclass); the declared types are not inspected or enforced. The actual
field type sent to the backend is inferred at runtime from the value you
pass — ``type(value)``, or ``str`` when the value is ``None``. Mismatches
between the annotation and the value are therefore harmless, and using
``typing.Any`` is fine if you do not want to commit to a static type::
class MyConfig(opik.Config):
temperature: float = 0.7 # default value — used when
model: str = "gpt-4" # no arg is passed
hint: typing.Any = None # type inferred from the value
# actually used at runtime
Publish a version via :meth:`opik.Opik.create_config`::
cfg = MyConfig(temperature=0.5) # defaults fill in the rest
client.create_config(cfg, project_name="my-project")
Retrieve (or auto-create from fallback) via :meth:`opik.Opik.get_or_create_config`::
result = client.get_or_create_config(
fallback=MyConfig(),
project_name="my-project",
)
"""
__field_names__: typing.ClassVar[typing.Tuple[str, ...]] = ()
def __init_subclass__(cls, **kwargs: typing.Any) -> None:
super().__init_subclass__(**kwargs)
if not dataclasses.is_dataclass(cls):
dataclasses.dataclass(cls)
cls.__field_names__ = tuple(
f.name
for f in dataclasses.fields(cls) # type: ignore[arg-type]
)
def __init__(self) -> None:
# Base-class instantiation path used when ``get_or_create_config`` is
# called without a fallback. Subclasses override this via the
# dataclass-generated ``__init__``, which still triggers
# ``__post_init__`` below.
self.__post_init__()
def __post_init__(self) -> None:
object.__setattr__(self, "_opik_state", _OpikState())
@property
def _state(self) -> _OpikState:
return object.__getattribute__(self, "_opik_state")
@property
def is_fallback(self) -> bool:
"""True if local fallback values are used because there was an issue communicating with the backend."""
return self._state.is_fallback
def _infer_field_types(self) -> typing.Dict[str, typing.Any]:
"""Return ``{field_name: python_type}`` derived from this instance's values."""
return {
name: _infer_python_type(object.__getattribute__(self, name))
for name in type(self).__field_names__
}
def __getattribute__(self, attr: str) -> typing.Any:
field_names = type(self).__field_names__
if attr in field_names:
if self._state.project is None:
return object.__getattribute__(self, attr)
return self._resolve_field(attr)
# Generic ``Config`` instances (no declared schema) resolve unknown
# attributes from the live cache so users can access backend values
# even when ``get_or_create_config`` was called without a fallback.
if (
not field_names
and not attr.startswith("_")
and not hasattr(type(self), attr)
and self._state.project is not None
):
return self._resolve_field(attr)
return object.__getattribute__(self, attr)
def _resolve_field(self, attr: str) -> typing.Any:
state = self._state
project = typing.cast(str, state.project)
instance_cache = cache_mod.get_cached_config(
project, state.env, state.mask_id, state.version
)
state.blueprint_id = instance_cache.blueprint_id
state.blueprint_version = instance_cache.blueprint_version
state.is_fallback = instance_cache.blueprint_id is None
value = instance_cache.values.get(attr, _MISSING)
self._inject_trace_metadata(attr, value=value)
return value if value is not _MISSING else object.__getattribute__(self, attr)
def _extract_fields_with_values(self) -> typing.Dict[str, types.FieldValueSpec]:
result: typing.Dict[str, types.FieldValueSpec] = {}
for name in type(self).__field_names__:
value = object.__getattribute__(self, name)
result[name] = types.FieldValueSpec(
python_type=_infer_python_type(value),
value=value,
)
return result
@classmethod
def _get_or_create_from_backend(
cls: typing.Type[T],
manager: typing.Any,
project_name: str,
*,
fallback: typing.Optional[T] = None,
env: typing.Optional[str],
version: typing.Optional[str],
auto_create_if_empty: bool,
timeout_in_seconds: typing.Optional[int],
) -> T:
_require_track_context()
version, mask_id = _apply_context_overrides(version)
# A runner context that pins a specific blueprint name is an explicit
# version request — missing it must raise ConfigNotFound, not auto-create.
if get_active_config_blueprint_name() is not None:
auto_create_if_empty = False
resolved_env = None if version is not None else env
# Field types come from the fallback's runtime values when available;
# without a fallback we pass an empty mapping and rely on the
# backend-declared type for each value (see Blueprint._convert_primitives).
field_types: typing.Dict[str, typing.Any] = (
fallback._infer_field_types() if fallback is not None else {}
)
try:
bp = _fetch_by_selector(
manager,
version=version,
env=env,
mask_id=mask_id,
field_types=field_types,
timeout_in_seconds=timeout_in_seconds,
)
except Exception:
if fallback is None:
raise
_init_fallback_cache_entry(
project_name, resolved_env, mask_id, field_types, manager, version
)
return fallback
if bp is not None:
return _build_live_instance(
cls,
bp,
project_name=project_name,
resolved_env=resolved_env,
mask_id=mask_id,
version=version,
manager=manager,
field_types=field_types,
)
if not auto_create_if_empty:
raise _missing_config_error(project_name, env=env, version=version)
# env="prod" default path: the initial fetch filtered by env, so probe
# project-wide to distinguish "project empty" (auto-create) from
# "prod tag missing while other configs exist" (surface ConfigNotFound).
# The version="latest" path already queried the project-wide latest.
if env is not None:
try:
probe = manager.get_blueprint(
field_types=field_types,
timeout_in_seconds=timeout_in_seconds,
)
except Exception:
if fallback is None:
raise
_init_fallback_cache_entry(
project_name, resolved_env, mask_id, field_types, manager, version
)
return fallback
if probe is not None:
raise ConfigNotFound(
f"No config tagged with env={env!r} in project {project_name!r}, "
f"but other configs exist. Tag a version with env={env!r} "
f"via set_config_env(), or pass an explicit env/version."
)
if fallback is None:
raise ConfigNotFound(
f"No config found in project {project_name!r}. Pass a `fallback` "
f"to auto-create one."
)
return cls._create_from_fallback(
fallback=fallback,
manager=manager,
project_name=project_name,
mask_id=mask_id,
field_types=field_types,
timeout_in_seconds=timeout_in_seconds,
)
@classmethod
def _create_from_fallback(
cls: typing.Type[T],
fallback: T,
manager: typing.Any,
project_name: str,
mask_id: typing.Optional[str],
field_types: typing.Dict[str, typing.Any],
timeout_in_seconds: typing.Optional[int],
) -> T:
_validate_prompt_project_names(fallback, project_name)
# Before auto-creating from fallback, check that all prompt fields have
# been persisted to the backend (non-None commit). An unsynced prompt
# has no commit, so storing it would produce a broken blueprint.
if not _all_prompts_synced(fallback):
logger.debug(
"One or more prompt fields in the fallback have not been synced with "
"the backend yet. Returning the fallback config without creating a blueprint."
)
return fallback
fields_with_values = fallback._extract_fields_with_values()
try:
bp = manager.create_blueprint(
fields_with_values=fields_with_values,
field_types=field_types,
)
except rest_api_core.ApiError as e:
if e.status_code != 409:
raise
# Parallel caller created it first — fetch the current latest.
bp = manager.get_blueprint(
field_types=field_types,
timeout_in_seconds=timeout_in_seconds,
)
if bp is None:
raise ConfigNotFound(
f"Failed to create or fetch config in project {project_name!r}."
)
return _build_live_instance(
cls,
bp,
project_name=project_name,
resolved_env=None,
mask_id=mask_id,
version=None,
manager=manager,
field_types=field_types,
)
def _create_from_instance(
self,
manager: typing.Any,
description: typing.Optional[str] = None,
) -> str:
_validate_prompt_project_names(self, manager.project_name)
if not _all_prompts_synced(self):
raise ConfigMismatch(
"One or more prompt fields have not been persisted to the backend yet "
"(commit is None). Persist all prompts before calling create_config()."
)
fields_with_values = self._extract_fields_with_values()
field_types = self._infer_field_types()
latest = manager.get_blueprint(field_types=field_types)
if latest is not None:
bp = manager.update_blueprint(
fields_with_values=fields_with_values,
description=description,
field_types=field_types,
)
else:
try:
bp = manager.create_blueprint(
fields_with_values=fields_with_values,
description=description,
field_types=field_types,
)
except rest_api_core.ApiError as e:
if e.status_code != 409:
raise
bp = manager.update_blueprint(
fields_with_values=fields_with_values,
description=description,
field_types=field_types,
)
self._state.manager = manager
self._state.blueprint_id = bp.id
self._state.blueprint_version = bp.name
self._state.is_fallback = False
return bp.name or ""
def _inject_trace_metadata(self, attr: str, value: typing.Any = _MISSING) -> None:
from opik import exceptions, opik_context
try:
metadata = self._build_trace_metadata(attr, value)
payload = {"agent_configuration": metadata}
opik_context.update_current_trace(metadata=payload)
opik_context.update_current_span(metadata=payload)
except exceptions.OpikException:
pass
except Exception:
logger.debug("Failed to inject config metadata into trace", exc_info=True)
def _build_trace_metadata(
self,
attr: str,
value: typing.Any,
) -> typing.Dict[str, typing.Any]:
state = self._state
if value is not _MISSING:
py_type = _infer_python_type(value)
values: typing.Dict[str, typing.Any] = {
attr: {
"value": type_helpers.python_value_to_metadata_value(
value, py_type
),
"type": type_helpers.python_type_to_backend_type(py_type),
}
}
else:
values = {}
result: typing.Dict[str, typing.Any] = {
"_blueprint_id": state.blueprint_id,
"blueprint_version": state.blueprint_version,
}
if state.mask_id is not None:
result["_mask_id"] = state.mask_id
result["values"] = values
return result
@@ -0,0 +1,164 @@
import copy
import datetime
import typing
from opik.rest_api import client as rest_client
from opik.rest_api.types.agent_blueprint_public import AgentBlueprintPublic
from opik.api_objects.prompt.text.prompt import Prompt
from opik.api_objects.prompt.chat.chat_prompt import ChatPrompt
from opik.rest_api.types.prompt_version_detail import PromptVersionDetail
from .. import type_helpers
def _resolve_prompt_from_commit(
rest_client_: rest_client.OpikApi, commit: str
) -> typing.Any:
prompt_detail = rest_client_.prompts.get_prompt_by_commit(commit)
version_detail = prompt_detail.requested_version
if (
prompt_detail.template_structure == "chat"
or version_detail.template_structure == "chat"
):
return ChatPrompt.from_fern_prompt_version(
name=prompt_detail.name, prompt_version=version_detail
)
return Prompt.from_fern_prompt_version(
name=prompt_detail.name, prompt_version=version_detail
)
def _resolve_prompt_version_from_commit(
rest_client_: rest_client.OpikApi, commit: str
) -> PromptVersionDetail:
prompt_detail = rest_client_.prompts.get_prompt_by_commit(commit)
return prompt_detail.requested_version
def _convert_primitives(
raw_blueprint: AgentBlueprintPublic,
field_types: typing.Optional[typing.Dict[str, typing.Any]],
) -> typing.Dict[str, typing.Any]:
values: typing.Dict[str, typing.Any] = {}
for param in raw_blueprint.values:
if field_types and param.key in field_types:
py_type = field_types[param.key]
else:
py_type = type_helpers.backend_type_to_python_type(param.type)
if py_type is not None:
values[param.key] = type_helpers.backend_value_to_python_value(
param.value, py_type
)
else:
values[param.key] = param.value
return values
def _is_prompt_field(
key: str,
backend_type: str,
field_types: typing.Optional[typing.Dict[str, typing.Any]],
) -> bool:
if field_types and key in field_types:
return type_helpers.is_prompt_type(field_types[key])
return backend_type == "prompt"
def _is_prompt_version_field(
key: str,
backend_type: str,
field_types: typing.Optional[typing.Dict[str, typing.Any]],
) -> bool:
if field_types and key in field_types:
return type_helpers.is_prompt_version_type(field_types[key])
return backend_type == "prompt_commit"
def _resolve_prompts(
raw_blueprint: AgentBlueprintPublic,
values: typing.Dict[str, typing.Any],
field_types: typing.Optional[typing.Dict[str, typing.Any]],
rest_client_: rest_client.OpikApi,
) -> None:
for param in raw_blueprint.values:
raw_value = values.get(param.key)
if not isinstance(raw_value, str):
continue
if _is_prompt_field(param.key, param.type, field_types):
values[param.key] = _resolve_prompt_from_commit(rest_client_, raw_value)
elif _is_prompt_version_field(param.key, param.type, field_types):
values[param.key] = _resolve_prompt_version_from_commit(
rest_client_, raw_value
)
def _resolve_values(
raw_blueprint: AgentBlueprintPublic,
field_types: typing.Optional[typing.Dict[str, typing.Any]],
rest_client_: typing.Optional[rest_client.OpikApi],
) -> typing.Dict[str, typing.Any]:
values = _convert_primitives(raw_blueprint, field_types)
if rest_client_:
_resolve_prompts(raw_blueprint, values, field_types, rest_client_)
return values
class Blueprint:
"""A specific versioned snapshot of agent config values (read-only)."""
def __init__(
self,
raw_blueprint: AgentBlueprintPublic,
field_types: typing.Optional[typing.Dict[str, typing.Any]] = None,
rest_client_: typing.Optional[rest_client.OpikApi] = None,
) -> None:
self._raw = raw_blueprint
self._values = _resolve_values(raw_blueprint, field_types, rest_client_)
self._descriptions: typing.Dict[str, typing.Optional[str]] = {
param.key: param.description for param in raw_blueprint.values
}
@property
def id(self) -> typing.Optional[str]:
return self._raw.id
@property
def name(self) -> typing.Optional[str]:
return self._raw.name
@property
def description(self) -> typing.Optional[str]:
return self._raw.description
@property
def type(self) -> typing.Optional[str]:
return self._raw.type
@property
def envs(self) -> typing.Optional[typing.List[str]]:
return self._raw.envs
@property
def created_by(self) -> typing.Optional[str]:
return self._raw.created_by
@property
def created_at(self) -> typing.Optional[datetime.datetime]:
return self._raw.created_at
@property
def values(self) -> typing.Dict[str, typing.Any]:
return copy.deepcopy(self._values)
def get(self, key: str, default: typing.Any = None) -> typing.Any:
return self._values.get(key, default)
def __getitem__(self, key: str) -> typing.Any:
return self._values[key]
def keys(self) -> typing.KeysView[str]:
return self._values.keys()
def get_field_description(self, key: str) -> typing.Optional[str]:
return self._descriptions.get(key)
@@ -0,0 +1,217 @@
import atexit
import logging
import os
import threading
import time
import typing
from .blueprint import Blueprint
logger = logging.getLogger(__name__)
DEFAULT_TTL_SECONDS = 300
_MIN_REFRESH_INTERVAL_SECONDS = 1.0
_CacheKey = typing.Tuple[
str, typing.Optional[str], typing.Optional[str], typing.Optional[str]
]
def _get_ttl_seconds() -> int:
raw = os.environ.get("OPIK_CONFIG_TTL_SECONDS")
if raw is not None:
try:
return int(raw)
except ValueError:
pass
return DEFAULT_TTL_SECONDS
class SharedConfigCache:
def __init__(self, ttl_seconds: int = DEFAULT_TTL_SECONDS) -> None:
self._lock = threading.RLock()
self.blueprint_id: typing.Optional[str] = None
self.blueprint_version: typing.Optional[str] = None
self.values: typing.Dict[str, typing.Any] = {}
self._registered_field_types: typing.Dict[str, typing.Any] = {}
self._ttl_seconds = ttl_seconds
self._last_fetch: typing.Optional[float] = None
self._refresh_callback: typing.Optional[
typing.Callable[[], typing.Optional[Blueprint]]
] = None
def set_refresh_callback(
self, callback: typing.Callable[[], typing.Optional[Blueprint]]
) -> None:
with self._lock:
if self._refresh_callback is None:
self._refresh_callback = callback
def register_fields(self, field_types: typing.Dict[str, typing.Any]) -> None:
with self._lock:
self._registered_field_types.update(field_types)
@property
def all_field_types(self) -> typing.Dict[str, typing.Any]:
with self._lock:
return dict(self._registered_field_types)
def update(self, blueprint: Blueprint) -> None:
new_values = dict(blueprint._values)
with self._lock:
self.blueprint_id = blueprint.id
self.blueprint_version = blueprint.name
self.values = new_values
self._last_fetch = time.monotonic()
def value_keys(self) -> typing.Set[str]:
with self._lock:
return set(self.values.keys())
def is_stale(self) -> bool:
with self._lock:
if self._last_fetch is None:
return True
return (time.monotonic() - self._last_fetch) >= self._ttl_seconds
def try_background_refresh(self) -> None:
with self._lock:
callback = self._refresh_callback
if callback is None:
return
try:
bp = callback()
if bp is not None:
self.update(bp)
except Exception:
logger.debug("Background cache refresh failed", exc_info=True)
class CacheRefreshThread(threading.Thread):
def __init__(
self,
get_caches: typing.Callable[[], typing.List[SharedConfigCache]],
interval_seconds: typing.Optional[float] = None,
) -> None:
super().__init__(daemon=True, name="OpikCacheRefresh")
self._get_caches = get_caches
self._stop_event = threading.Event()
self._interval = interval_seconds
def run(self) -> None:
while not self._stop_event.is_set():
self._refresh_all_stale()
interval = self._interval or float(_get_ttl_seconds())
self._stop_event.wait(max(interval, _MIN_REFRESH_INTERVAL_SECONDS))
def _refresh_all_stale(self) -> None:
for cache in self._get_caches():
if self._stop_event.is_set():
break
if cache.is_stale():
cache.try_background_refresh()
def close(self) -> None:
self._stop_event.set()
class SharedCacheRegistry:
def __init__(self) -> None:
self._lock = threading.RLock()
self._caches: typing.Dict[_CacheKey, SharedConfigCache] = {}
self._thread: typing.Optional[CacheRefreshThread] = None
self._thread_lock = threading.Lock()
def get(
self,
project_name: str,
env: typing.Optional[str],
mask_id: typing.Optional[str],
version: typing.Optional[str] = None,
) -> SharedConfigCache:
key: _CacheKey = (project_name, env, mask_id, version)
with self._lock:
if key not in self._caches:
self._caches[key] = SharedConfigCache(ttl_seconds=_get_ttl_seconds())
return self._caches[key]
def clear(self) -> None:
self.stop_refresh_thread()
with self._lock:
self._caches.clear()
def ensure_refresh_thread_started(self) -> None:
with self._thread_lock:
if self._thread is not None and self._thread.is_alive():
return
self._thread = CacheRefreshThread(
get_caches=lambda: list(self._caches.values())
)
self._thread.start()
atexit.register(self.stop_refresh_thread)
def stop_refresh_thread(self) -> None:
with self._thread_lock:
if self._thread is not None:
thread = self._thread
thread.close()
try:
thread.join(timeout=5)
if thread.is_alive():
logger.error(
"Cache refresh thread did not stop within the timeout."
)
except Exception:
logger.exception(
"Unexpected error while waiting for cache refresh thread to stop."
)
self._thread = None
_registry = SharedCacheRegistry()
def get_global_registry() -> SharedCacheRegistry:
return _registry
# ---------------------------------------------------------------------------
# Module-level helpers (used by base.py and opik_client.py)
# ---------------------------------------------------------------------------
def get_cached_config(
project_name: str,
env: typing.Optional[str],
mask_id: typing.Optional[str],
version: typing.Optional[str] = None,
) -> SharedConfigCache:
return _registry.get(project_name, env, mask_id, version)
def init_cache_entry(
project_name: str,
env: typing.Optional[str],
mask_id: typing.Optional[str],
field_types: typing.Dict[str, typing.Any],
agent_config_manager: typing.Any,
blueprint: typing.Optional[Blueprint] = None,
version: typing.Optional[str] = None,
) -> None:
shared_cache = _registry.get(project_name, env, mask_id, version)
shared_cache.register_fields(field_types)
if blueprint is not None:
shared_cache.update(blueprint)
if agent_config_manager is not None and mask_id is None and version is None:
def _refresh() -> typing.Optional[Blueprint]:
return agent_config_manager.get_blueprint(
env=env,
mask_id=mask_id,
field_types=shared_cache.all_field_types,
)
shared_cache.set_refresh_callback(_refresh)
_registry.ensure_refresh_thread_started()
@@ -0,0 +1,296 @@
import typing
from opik.rest_api import client as rest_client
from opik.rest_api import core as rest_api_core
from opik.rest_api.core.request_options import RequestOptions
from opik.rest_api.types.agent_blueprint_write import AgentBlueprintWrite
from opik.rest_api.types.agent_config_env import AgentConfigEnv
from opik.rest_api.types.agent_config_value_write import AgentConfigValueWrite
from opik.api_objects import rest_helpers
from opik import id_helpers
from .blueprint import Blueprint
from . import cache as cache_mod, types
from .. import type_helpers
class ConfigManager:
"""Project-level config entity — internal REST operations."""
def __init__(
self,
project_name: str,
rest_client_: rest_client.OpikApi,
) -> None:
self._project_name = project_name
self._rest_client = rest_client_
@property
def project_name(self) -> str:
return self._project_name
@staticmethod
def _resolve_fields_with_values(
parameters: typing.Optional[typing.Dict[str, typing.Any]],
fields_with_values: typing.Optional[typing.Dict[str, types.FieldValueSpec]],
) -> typing.Dict[str, types.FieldValueSpec]:
if fields_with_values is not None:
return fields_with_values
return {
k: types.FieldValueSpec(
python_type=type(v) if v is not None else str, value=v
)
for k, v in (parameters or {}).items()
}
def get_blueprint(
self,
*,
name: typing.Optional[str] = None,
env: typing.Optional[str] = None,
mask_id: typing.Optional[str] = None,
field_types: typing.Optional[typing.Dict[str, typing.Any]] = None,
timeout_in_seconds: typing.Optional[int] = None,
) -> typing.Optional[Blueprint]:
"""Fetch a blueprint by name, environment name, or latest.
Priority: ``name`` > ``env`` > latest.
Returns ``None`` if not found.
Args:
name: Fetch the blueprint with this version name.
env: Fetch the blueprint tagged with this environment name.
mask_id: ID of a mask blueprint to overlay on the result.
field_types: Mapping of field name to Python type used
for deserialising backend values.
timeout_in_seconds: HTTP request timeout in seconds.
"""
request_options: typing.Optional[RequestOptions] = (
RequestOptions(timeout_in_seconds=timeout_in_seconds)
if timeout_in_seconds is not None
else None
)
try:
project_id = rest_helpers.resolve_project_id_by_name(
self._rest_client, self._project_name
)
if name is not None:
raw = self._rest_client.agent_configs.get_blueprint_by_name(
project_id=project_id,
name=name,
mask_id=mask_id,
request_options=request_options,
)
elif env is not None:
raw = self._rest_client.agent_configs.get_blueprint_by_env(
env_name=env,
project_id=project_id,
mask_id=mask_id,
request_options=request_options,
)
else:
raw = self._rest_client.agent_configs.get_latest_blueprint(
project_id=project_id,
mask_id=mask_id,
request_options=request_options,
)
except rest_api_core.ApiError as e:
if e.status_code == 404:
return None
raise
return Blueprint(
raw_blueprint=raw,
field_types=field_types,
rest_client_=self._rest_client,
)
def create_blueprint(
self,
parameters: typing.Optional[typing.Dict[str, typing.Any]] = None,
fields_with_values: typing.Optional[
typing.Dict[str, types.FieldValueSpec]
] = None,
description: typing.Optional[str] = None,
field_types: typing.Optional[typing.Dict[str, typing.Any]] = None,
) -> Blueprint:
"""Create and return the initial blueprint for this agent config (first version only).
Use this method to establish the first version of the agent config.
For subsequent updates use :meth:`update_blueprint`.
Pass either ``parameters`` (plain key-value pairs whose types are
inferred) or ``fields_with_values`` (explicit ``{key: types.FieldValueSpec(type, value)}``
mapping). If both are given ``fields_with_values`` takes precedence.
Args:
parameters: Plain ``{field_name: value}`` dict; types are inferred
via ``type(value)``.
fields_with_values: Explicit ``{field_name: types.FieldValueSpec(python_type, value)}``
mapping, bypassing type inference.
description: Human-readable description stored with the blueprint.
field_types: Mapping of field name to Python type used
when fetching back the created blueprint.
"""
resolved_fields_with_values = self._resolve_fields_with_values(
parameters, fields_with_values
)
blueprint_id = id_helpers.generate_id()
payload = _build_blueprint_payload(
resolved_fields_with_values, description, id=blueprint_id
)
self._rest_client.agent_configs.create_agent_config(
blueprint=payload,
project_name=self._project_name,
)
raw = self._rest_client.agent_configs.get_blueprint_by_id(blueprint_id)
return Blueprint(
raw_blueprint=raw,
field_types=field_types,
rest_client_=self._rest_client,
)
def update_blueprint(
self,
fields_with_values: typing.Optional[
typing.Dict[str, types.FieldValueSpec]
] = None,
description: typing.Optional[str] = None,
field_types: typing.Optional[typing.Dict[str, typing.Any]] = None,
) -> Blueprint:
"""Create a new blueprint with only the supplied fields (not merged with previous).
Each call creates a new versioned snapshot containing exactly the fields
you provide in ``fields_with_values``. Fields omitted from that mapping
are **not** carried over from the previous version.
``fields_with_values`` must map each field name to a
:class:`~opik.api_objects.agent_config.types.FieldValueSpec` that
declares both the Python type and the value. For prompt fields the
value must be a :class:`~opik.api_objects.prompt.text.prompt.Prompt`
(or :class:`~opik.api_objects.prompt.chat.chat_prompt.ChatPrompt`)
instance::
config.update_blueprint(
fields_with_values={
"system_prompt": types.FieldValueSpec(
python_type=Prompt, value=my_prompt
),
"temperature": types.FieldValueSpec(
python_type=float, value=0.7
),
}
)
Args:
fields_with_values: ``{field_name: types.FieldValueSpec(python_type, value)}``
mapping. Each entry overrides that field in the new blueprint.
description: Human-readable description stored with the blueprint.
field_types: Mapping of field name to Python type used
when fetching back the created blueprint.
"""
resolved_fields_with_values = self._resolve_fields_with_values(
None, fields_with_values
)
blueprint_id = id_helpers.generate_id()
payload = _build_blueprint_payload(
resolved_fields_with_values, description, id=blueprint_id
)
self._rest_client.agent_configs.update_agent_config(
blueprint=payload,
project_name=self._project_name,
)
raw = self._rest_client.agent_configs.get_blueprint_by_id(blueprint_id)
return Blueprint(
raw_blueprint=raw,
field_types=field_types,
rest_client_=self._rest_client,
)
def set_env(self, version: str, env: str) -> None:
"""Tag a specific blueprint version with an environment name.
After tagging, ``get_blueprint(env=env)`` will return this version.
Args:
version: Version name of the blueprint to tag.
env: Environment name (e.g. ``"prod"``, ``"staging"``).
"""
project_id = rest_helpers.resolve_project_id_by_name(
self._rest_client, self._project_name
)
# Use the cached blueprint_id when available to skip a round-trip.
cached = cache_mod.get_cached_config(self._project_name, None, None, version)
blueprint_id = cached.blueprint_id
if blueprint_id is None:
blueprint = self._rest_client.agent_configs.get_blueprint_by_name(
project_id=project_id,
name=version,
)
blueprint_id = blueprint.id
self._rest_client.agent_configs.create_or_update_envs(
project_id=project_id,
envs=[AgentConfigEnv(env_name=env, blueprint_id=blueprint_id)],
)
def create_mask(
self,
parameters: typing.Optional[typing.Dict[str, typing.Any]] = None,
fields_with_values: typing.Optional[
typing.Dict[str, types.FieldValueSpec]
] = None,
description: typing.Optional[str] = None,
) -> str:
"""Create a mask blueprint and return its ID.
A mask overlays a subset of fields on top of an existing blueprint.
Apply it by passing the returned ID to ``get_blueprint(mask_id=...)``.
Args:
parameters: Plain ``{field_name: value}`` dict; types are inferred
via ``type(value)``.
fields_with_values: Explicit ``{field_name: types.FieldValueSpec(python_type, value)}``
mapping, bypassing type inference.
description: Human-readable description stored with the mask.
"""
resolved_fields_with_values = self._resolve_fields_with_values(
parameters, fields_with_values
)
mask_id = id_helpers.generate_id()
payload = _build_blueprint_payload(
resolved_fields_with_values, description, id=mask_id, config_type="mask"
)
self._rest_client.agent_configs.update_agent_config(
blueprint=payload,
project_name=self._project_name,
)
return mask_id
def _build_blueprint_payload(
fields_with_values: typing.Dict[str, types.FieldValueSpec],
description: typing.Optional[str],
id: typing.Optional[str] = None,
config_type: str = "blueprint",
) -> AgentBlueprintWrite:
backend_values = []
for field_name, field_spec in fields_with_values.items():
backend_type = (
type_helpers.python_type_to_backend_type(field_spec.python_type)
if field_spec.value is not None
else "string"
)
backend_values.append(
AgentConfigValueWrite(
key=field_name,
type=backend_type,
value=type_helpers.python_value_to_backend_value(
field_spec.value, field_spec.python_type
),
)
)
return AgentBlueprintWrite(
id=id,
type=config_type,
values=backend_values,
description=description,
)
@@ -0,0 +1,33 @@
import contextvars
from contextlib import contextmanager
from typing import Optional, Generator
_active_config_mask_var: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
"opik_active_config_mask", default=None
)
_active_config_blueprint_name_var: contextvars.ContextVar[Optional[str]] = (
contextvars.ContextVar("opik_active_config_blueprint_name", default=None)
)
@contextmanager
def agent_config_context(
mask_id: Optional[str],
blueprint_name: Optional[str] = None,
) -> Generator[None, None, None]:
mask_token = _active_config_mask_var.set(mask_id)
blueprint_token = _active_config_blueprint_name_var.set(blueprint_name)
try:
yield
finally:
_active_config_mask_var.reset(mask_token)
_active_config_blueprint_name_var.reset(blueprint_token)
def get_active_config_mask() -> Optional[str]:
return _active_config_mask_var.get()
def get_active_config_blueprint_name() -> Optional[str]:
return _active_config_blueprint_name_var.get()
@@ -0,0 +1,13 @@
import typing
class FieldValueSpec(typing.NamedTuple):
"""Describes a single blueprint field's value for write operations.
Attributes:
python_type: The Python type of the field (e.g. ``str``, ``int``).
value: The field value to write.
"""
python_type: type[typing.Any]
value: typing.Any
@@ -0,0 +1,12 @@
from .annotation_queue import (
BaseAnnotationQueue,
TracesAnnotationQueue,
ThreadsAnnotationQueue,
)
__all__ = [
"BaseAnnotationQueue",
"TracesAnnotationQueue",
"ThreadsAnnotationQueue",
]
@@ -0,0 +1,412 @@
import logging
from abc import ABC, abstractmethod
from typing import (
Optional,
List,
Literal,
Union,
)
from opik.rest_api import client as rest_api_client
from opik.rest_api.types import (
trace_public,
trace_thread,
trace_filter_public,
trace_thread_filter,
)
from opik.message_processing.batching import sequence_splitter
from opik.api_objects.trace import trace_client
from opik.api_objects.rest_helpers import ensure_rest_api_call_respecting_rate_limit
from opik.api_objects import constants, helpers, search_helpers
import opik.exceptions as exceptions
LOGGER = logging.getLogger(__name__)
TraceType = Union[trace_client.Trace, trace_public.TracePublic]
class BaseAnnotationQueue(ABC):
"""
Base class for annotation queue objects.
This class provides the common functionality shared between
TracesAnnotationQueue and ThreadsAnnotationQueue.
This object should not be created directly, instead use the appropriate
create/get methods on opik.Opik client.
"""
def __init__(
self,
id: str,
name: str,
project_id: str,
rest_client: rest_api_client.OpikApi,
description: Optional[str] = None,
instructions: Optional[str] = None,
comments_enabled: Optional[bool] = None,
feedback_definition_names: Optional[List[str]] = None,
items_count: Optional[int] = None,
) -> None:
self._id = id
self._name = name
self._description = description
self._instructions = instructions
self._project_id = project_id
self._comments_enabled = comments_enabled
self._feedback_definition_names = feedback_definition_names
self._items_count = items_count
self._rest_client = rest_client
@property
def id(self) -> str:
"""The id of the annotation queue."""
return self._id
@property
def name(self) -> str:
"""The name of the annotation queue."""
return self._name
@property
def description(self) -> Optional[str]:
"""The description of the annotation queue."""
return self._description
@property
def instructions(self) -> Optional[str]:
"""The instructions for reviewers."""
return self._instructions
@property
def project_id(self) -> str:
"""The project ID associated with this annotation queue."""
return self._project_id
@property
def comments_enabled(self) -> Optional[bool]:
"""Whether comments are enabled for this queue."""
return self._comments_enabled
@property
def feedback_definition_names(self) -> Optional[List[str]]:
"""The feedback definition names associated with this queue."""
return self._feedback_definition_names
@property
@abstractmethod
def scope(self) -> str:
"""The scope of the annotation queue ('trace' or 'thread')."""
pass
@property
def items_count(self) -> Optional[int]:
"""
The total number of items in the queue.
If the count is not cached locally, it will be fetched from the backend.
"""
if self._items_count is None:
queue_info = self._rest_client.annotation_queues.get_annotation_queue_by_id(
self._id
)
self._items_count = queue_info.items_count
return self._items_count
def update(
self,
name: Optional[str] = None,
description: Optional[str] = None,
instructions: Optional[str] = None,
comments_enabled: Optional[bool] = None,
feedback_definition_names: Optional[List[str]] = None,
) -> None:
"""
Update the annotation queue properties.
Args:
name: New name for the queue.
description: New description for the queue.
instructions: New instructions for reviewers.
comments_enabled: Whether to enable comments.
feedback_definition_names: List of feedback definition names.
"""
self._rest_client.annotation_queues.update_annotation_queue(
id=self._id,
name=name,
description=description,
instructions=instructions,
comments_enabled=comments_enabled,
feedback_definition_names=feedback_definition_names,
)
if name is not None:
self._name = name
if description is not None:
self._description = description
if instructions is not None:
self._instructions = instructions
if comments_enabled is not None:
self._comments_enabled = comments_enabled
if feedback_definition_names is not None:
self._feedback_definition_names = feedback_definition_names
def delete(self) -> None:
"""
Delete this annotation queue.
"""
self._rest_client.annotation_queues.delete_annotation_queue_batch(
ids=[self._id]
)
def _add_items_batch_with_retry(self, ids: List[str]) -> None:
"""Add a batch of items with automatic retry on rate limit errors."""
ensure_rest_api_call_respecting_rate_limit(
lambda: self._rest_client.annotation_queues.add_items_to_annotation_queue(
id=self._id, ids=ids
)
)
LOGGER.debug("Successfully added %d items to annotation queue", len(ids))
def _remove_items_batch_with_retry(self, ids: List[str]) -> None:
"""Remove a batch of items with automatic retry on rate limit errors."""
ensure_rest_api_call_respecting_rate_limit(
lambda: self._rest_client.annotation_queues.remove_items_from_annotation_queue(
id=self._id, ids=ids
)
)
LOGGER.debug("Successfully removed %d items from annotation queue", len(ids))
class TracesAnnotationQueue(BaseAnnotationQueue):
"""
An annotation queue for traces.
This queue is used to collect traces for human annotation workflows.
This object should not be created directly, instead use
:meth:`opik.Opik.create_traces_annotation_queue` or :meth:`opik.Opik.get_traces_annotation_queue`.
"""
SCOPE: Literal["trace"] = "trace"
@property
def scope(self) -> str:
"""The scope of the annotation queue."""
return self.SCOPE
def _extract_trace_ids(
self,
traces: List[TraceType],
) -> List[str]:
"""Extract IDs from trace objects."""
ids: List[str] = []
for trace in traces:
if trace.id is None:
raise exceptions.OpikException("Trace object has no id")
ids.append(trace.id)
return ids
def add_traces(
self,
traces: List[TraceType],
) -> None:
"""
Add trace objects to the annotation queue.
Args:
traces: A list of traces to add. For a single trace, wrap it in a list: [trace].
Accepts Trace objects (from opik_client.trace()) or TracePublic objects
(from search_traces()).
Raises:
OpikException: If any trace object has no id.
"""
ids = self._extract_trace_ids(traces)
if not ids:
return
batches = sequence_splitter.split_into_batches(
ids, max_length=constants.ANNOTATION_QUEUE_ITEMS_MAX_BATCH_SIZE
)
for batch in batches:
LOGGER.debug("Adding %d traces to annotation queue", len(batch))
self._add_items_batch_with_retry(batch)
self._items_count = None
def remove_traces(
self,
traces: List[TraceType],
) -> None:
"""
Remove trace objects from the annotation queue.
Args:
traces: A list of traces to remove. For a single trace, wrap it in a list: [trace].
Accepts Trace objects (from opik_client.trace()) or TracePublic objects
(from search_traces()).
Raises:
OpikException: If any trace object has no id.
"""
ids = self._extract_trace_ids(traces)
if not ids:
return
batches = sequence_splitter.split_into_batches(
ids, max_length=constants.ANNOTATION_QUEUE_ITEMS_MAX_BATCH_SIZE
)
for batch in batches:
LOGGER.debug("Removing %d traces from annotation queue", len(batch))
self._remove_items_batch_with_retry(batch)
self._items_count = None
def get_items(
self,
truncate_images: bool = True,
) -> List[trace_public.TracePublic]:
"""
Get all trace objects currently in the annotation queue.
Args:
truncate_images: Whether to truncate inline base64 image data stored in
input, output, or metadata of the returned traces.
Returns:
List[trace_public.TracePublic]: All traces currently in the queue.
"""
filters = helpers.parse_filter_expressions(
f'annotation_queue_ids contains "{self._id}"',
parsed_item_class=trace_filter_public.TraceFilterPublic,
entity_type="traces",
)
return search_helpers.search_traces_with_filters(
rest_client=self._rest_client,
project_id=self._project_id,
filters=filters,
max_results=None,
truncate=truncate_images,
)
class ThreadsAnnotationQueue(BaseAnnotationQueue):
"""
An annotation queue for threads.
This queue is used to collect threads for human annotation workflows.
This object should not be created directly, instead use
:meth:`opik.Opik.create_threads_annotation_queue` or :meth:`opik.Opik.get_threads_annotation_queue`.
"""
SCOPE: Literal["thread"] = "thread"
@property
def scope(self) -> str:
"""The scope of the annotation queue."""
return self.SCOPE
def _extract_thread_ids(
self,
threads: List[trace_thread.TraceThread],
) -> List[str]:
"""Extract thread_model_id from TraceThread objects."""
ids: List[str] = []
for thread in threads:
if thread.thread_model_id is None:
raise exceptions.OpikException(
"TraceThread object has no thread_model_id"
)
ids.append(thread.thread_model_id)
return ids
def add_threads(
self,
threads: List[trace_thread.TraceThread],
) -> None:
"""
Add thread objects to the annotation queue.
Args:
threads: A list of TraceThread objects to add (from search_threads()).
For a single thread, wrap it in a list: [thread].
Raises:
OpikException: If any thread object has no thread_model_id.
"""
ids = self._extract_thread_ids(threads)
if not ids:
return
batches = sequence_splitter.split_into_batches(
ids, max_length=constants.ANNOTATION_QUEUE_ITEMS_MAX_BATCH_SIZE
)
for batch in batches:
LOGGER.debug("Adding %d threads to annotation queue", len(batch))
self._add_items_batch_with_retry(batch)
self._items_count = None
def remove_threads(
self,
threads: List[trace_thread.TraceThread],
) -> None:
"""
Remove thread objects from the annotation queue.
Args:
threads: A list of TraceThread objects to remove (from search_threads()).
For a single thread, wrap it in a list: [thread].
Raises:
OpikException: If any thread object has no thread_model_id.
"""
ids = self._extract_thread_ids(threads)
if not ids:
return
batches = sequence_splitter.split_into_batches(
ids, max_length=constants.ANNOTATION_QUEUE_ITEMS_MAX_BATCH_SIZE
)
for batch in batches:
LOGGER.debug("Removing %d threads from annotation queue", len(batch))
self._remove_items_batch_with_retry(batch)
self._items_count = None
def get_items(
self,
truncate_images: bool = True,
) -> List[trace_thread.TraceThread]:
"""
Get all thread objects currently in the annotation queue.
Args:
truncate_images: Whether to truncate inline base64 image data stored in
input, output, or metadata of the returned threads.
Returns:
List[trace_thread.TraceThread]: All threads currently in the queue.
"""
filters = helpers.parse_filter_expressions(
f'annotation_queue_ids contains "{self._id}"',
parsed_item_class=trace_thread_filter.TraceThreadFilter,
entity_type="threads",
)
return search_helpers.search_threads_with_filters(
rest_client=self._rest_client,
project_id=self._project_id,
filters=filters,
max_results=None,
truncate=truncate_images,
)
@@ -0,0 +1,213 @@
from __future__ import annotations
import json
from typing import Callable, List, Optional, Type, TypeVar
from opik.rest_api import OpikApi
from opik.rest_api.types import AnnotationQueuePublic
import opik.exceptions as exceptions
from . import annotation_queue
from ...rest_api.core.api_error import ApiError
QueueT = TypeVar(
"QueueT",
annotation_queue.TracesAnnotationQueue,
annotation_queue.ThreadsAnnotationQueue,
)
def _create_queue_instance(
queue_data: AnnotationQueuePublic,
rest_client: OpikApi,
queue_class: Type[QueueT],
) -> QueueT:
"""Helper to create an annotation queue instance from API response data."""
return queue_class(
id=queue_data.id or "",
name=queue_data.name,
project_id=queue_data.project_id,
rest_client=rest_client,
description=queue_data.description,
instructions=queue_data.instructions,
comments_enabled=queue_data.comments_enabled,
feedback_definition_names=list(queue_data.feedback_definition_names)
if queue_data.feedback_definition_names
else None,
items_count=queue_data.items_count,
)
def _get_annotation_queues_by_scope(
rest_client: OpikApi,
queue_class: Type[QueueT],
scope_filter: Callable[[Optional[str]], bool],
project_id: Optional[str] = None,
max_results: int = 1000,
) -> List[QueueT]:
"""Helper to fetch annotation queues filtered by scope."""
page_size = 100
queues: List[QueueT] = []
filters: Optional[str] = None
if project_id is not None:
filters = json.dumps(
[
{
"field": "project_id",
"type": "string",
"operator": "=",
"value": project_id,
}
]
)
page = 1
while len(queues) < max_results:
page_queues = rest_client.annotation_queues.find_annotation_queues(
page=page,
size=page_size,
filters=filters,
)
if page_queues.content is None or len(page_queues.content) == 0:
break
for queue_data in page_queues.content:
if len(queues) >= max_results:
break
if scope_filter(queue_data.scope):
queues.append(
_create_queue_instance(queue_data, rest_client, queue_class)
)
page += 1
return queues
def _get_annotation_queue_by_id_with_scope(
rest_client: OpikApi,
queue_id: str,
queue_class: Type[QueueT],
scope_check: Callable[[Optional[str]], bool],
scope_name: str,
) -> QueueT:
"""Helper to fetch an annotation queue by ID with scope validation."""
try:
queue_data = rest_client.annotation_queues.get_annotation_queue_by_id(queue_id)
except ApiError as e:
if e.status_code == 404:
raise exceptions.OpikException(
f"Annotation queue with id '{queue_id}' not found."
) from e
raise
if not scope_check(queue_data.scope):
actual_scope = queue_data.scope or "trace"
raise exceptions.OpikException(
f"Annotation queue with id '{queue_id}' is not a {scope_name} queue (scope: {actual_scope})."
)
return _create_queue_instance(queue_data, rest_client, queue_class)
def get_traces_annotation_queues(
rest_client: OpikApi,
project_id: Optional[str] = None,
max_results: int = 1000,
) -> List[annotation_queue.TracesAnnotationQueue]:
"""
Fetch trace annotation queues with optional project filtering.
Args:
rest_client: The REST API client.
project_id: Optional project ID to filter queues.
max_results: Maximum number of queues to return.
Returns:
A list of TracesAnnotationQueue objects.
"""
return _get_annotation_queues_by_scope(
rest_client=rest_client,
queue_class=annotation_queue.TracesAnnotationQueue,
scope_filter=lambda s: s == "trace",
project_id=project_id,
max_results=max_results,
)
def get_threads_annotation_queues(
rest_client: OpikApi,
project_id: Optional[str] = None,
max_results: int = 1000,
) -> List[annotation_queue.ThreadsAnnotationQueue]:
"""
Fetch thread annotation queues with optional project filtering.
Args:
rest_client: The REST API client.
project_id: Optional project ID to filter queues.
max_results: Maximum number of queues to return.
Returns:
A list of ThreadsAnnotationQueue objects.
"""
return _get_annotation_queues_by_scope(
rest_client=rest_client,
queue_class=annotation_queue.ThreadsAnnotationQueue,
scope_filter=lambda s: s == "thread",
project_id=project_id,
max_results=max_results,
)
def get_traces_annotation_queue_by_id(
rest_client: OpikApi,
queue_id: str,
) -> annotation_queue.TracesAnnotationQueue:
"""
Fetch a trace annotation queue by its ID.
Args:
rest_client: The REST API client.
queue_id: The ID of the annotation queue.
Returns:
A TracesAnnotationQueue object.
Raises:
OpikException: If the queue is not found or is not a trace queue.
"""
return _get_annotation_queue_by_id_with_scope(
rest_client=rest_client,
queue_id=queue_id,
queue_class=annotation_queue.TracesAnnotationQueue,
scope_check=lambda s: s == "trace",
scope_name="traces",
)
def get_threads_annotation_queue_by_id(
rest_client: OpikApi,
queue_id: str,
) -> annotation_queue.ThreadsAnnotationQueue:
"""
Fetch a thread annotation queue by its ID.
Args:
rest_client: The REST API client.
queue_id: The ID of the annotation queue.
Returns:
A ThreadsAnnotationQueue object.
Raises:
OpikException: If the queue is not found or is not a thread queue.
"""
return _get_annotation_queue_by_id_with_scope(
rest_client=rest_client,
queue_id=queue_id,
queue_class=annotation_queue.ThreadsAnnotationQueue,
scope_check=lambda s: s == "thread",
scope_name="threads",
)
@@ -0,0 +1,5 @@
from .attachment import Attachment
from .client import AttachmentClient
__all__ = ["Attachment", "AttachmentClient"]
@@ -0,0 +1,28 @@
from typing import Optional, Union
import pydantic
class Attachment(pydantic.BaseModel):
"""
Represents an Attachment to be added to the Trace or Span.
Args:
data: The data to be added to the Attachment. Can be:
- A file path (str) to an existing file
- A base64-encoded string (str) representing file content
- Raw bytes content
file_name: The custom filename to assign to the data in the attachment.
If not provided, the original filename of the data will be used.
content_type: The MIME type of the data to be added to the attachment.
If not specified, it will be inferred from the data file.
create_temp_copy: If True, a temporary copy of the file will be created
before upload. This ensures the file remains available even if the
original is deleted. The temp file will be deleted after upload.
Default is True.
"""
data: Union[str, bytes]
file_name: Optional[str] = None
content_type: Optional[str] = None
create_temp_copy: bool = True
@@ -0,0 +1,36 @@
import dataclasses
from typing import Literal
from . import attachment
@dataclasses.dataclass
class AttachmentWithContext:
"""
Represents an attachment along with its associated context.
This class is used to pair an attachment with additional contextual
information such as the entity type, entity ID, project name, and
context description. It is specifically useful when dealing with
attachments related to entities like spans or traces. The context
can help provide further insights or classification of the
attachment's purpose.
Attributes:
attachment_data: The actual attachment
object containing the associated data.
entity_type: The type of entity the
attachment is associated with. It must be either "span"
or "trace".
entity_id: The unique identifier of the related entity.
project_name: The name of the project to which the
attachment and its entity belong.
context: A brief context description for the attachment,
explaining its purpose or relevance.
"""
attachment_data: attachment.Attachment
entity_type: Literal["span", "trace"]
entity_id: str
project_name: str
context: str
@@ -0,0 +1,215 @@
import re
from typing import Dict, Any, Literal, List, NamedTuple, Union
from . import attachment, attachment_context, decoder_base64
class ExtractionResult(NamedTuple):
attachments: List[attachment.Attachment]
sanitized_data: Any
class AttachmentsExtractor:
"""
Extracts and processes attachments embedded as Base64 strings within data structures.
This class is designed to identify and decode Base64-encoded attachments located
within the provided data. It uses a regular expression pattern to search for
Base64 strings that meet a specified minimum length. Extracted attachments are
decoded and replaced with sanitized placeholders in the original data.
"""
def __init__(self, min_attachment_size: int):
"""
Initializes the class with a minimum attachment size and configures the base64
pattern for decoding attachments based on its length.
Args:
min_attachment_size: The minimum size of the attachment in characters
for it to be considered valid. This ensures that only large enough
base64 strings are matched to minimize false positives.
"""
self._min_attachment_size = min_attachment_size
self.decoder = decoder_base64.Base64AttachmentDecoder()
# Pattern to match base64 strings (can be embedded in text)
# Requires at least min_attachment_size characters to reduce false positives.
# An optional `data:<mime>;base64,` prefix is matched too so that data URIs
# (e.g. OpenAI/LangChain image_url.url) are replaced whole, not just their payload.
min_base64_groups = int(min_attachment_size / 4)
BASE64_PATTERN = (
r"(?P<prefix>data:[^,]*;base64,)?"
r"(?P<base64>(?:[A-Za-z0-9+/]{4}){"
+ str(min_base64_groups)
+ r",}(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?)"
)
self.pattern = re.compile(BASE64_PATTERN)
def extract_and_replace(
self,
data: Union[Dict[str, Any], List[Any]],
entity_type: Literal["span", "trace"],
entity_id: str,
project_name: str,
context: Literal["input", "output", "metadata"],
) -> List[attachment_context.AttachmentWithContext]:
"""
Extract attachments from data and replace with placeholders.
Handles both dict and list at the top level, recursively processing
nested structures to find and extract base64-encoded attachments.
Args:
data: The data structure to process (dict or list)
entity_type: Type of entity (span or trace)
entity_id: ID of the entity
project_name: Name of the project
context: Context where data is located (input, output, or metadata)
Returns:
List of extracted attachments with context
"""
attachments: List[attachment_context.AttachmentWithContext] = []
if isinstance(data, dict):
# For dicts, iterate over items and extract attachments from values
for key, value in data.items():
extraction_result = self._try_extract_attachments(value, context)
if extraction_result.attachments:
# replace the original value with the sanitized one and collect attachments
data[key] = extraction_result.sanitized_data
for extracted_attachment in extraction_result.attachments:
attachments.append(
attachment_context.AttachmentWithContext(
attachment_data=extracted_attachment,
entity_type=entity_type,
entity_id=entity_id,
project_name=project_name,
context=context,
)
)
elif isinstance(data, list):
# For lists, extract attachments from each item and replace in place
extraction_result = self._try_extract_attachments(data, context)
# Replace list contents with sanitized version
data.clear()
data.extend(extraction_result.sanitized_data)
# Convert extracted attachments to AttachmentWithContext
for extracted_attachment in extraction_result.attachments:
attachments.append(
attachment_context.AttachmentWithContext(
attachment_data=extracted_attachment,
entity_type=entity_type,
entity_id=entity_id,
project_name=project_name,
context=context,
)
)
else:
# For other types (str, int, bool, None, etc.), try to extract but don't mutate
extraction_result = self._try_extract_attachments(data, context)
for extracted_attachment in extraction_result.attachments:
attachments.append(
attachment_context.AttachmentWithContext(
attachment_data=extracted_attachment,
entity_type=entity_type,
entity_id=entity_id,
project_name=project_name,
context=context,
)
)
return attachments
def _try_extract_attachments(
self, data: Any, context: Literal["input", "output", "metadata"]
) -> ExtractionResult:
"""
Recursively extract attachments from data that can be a string, dict, list, or other type.
Args:
data: The data to process (can be str, dict, list, or other types)
context: The context where the data is located (input, output, or metadata)
Returns:
ExtractionResult with extracted attachments and sanitized data
"""
# Handle string data - check for base64 attachments
if isinstance(data, str):
return self._extract_from_string(data, context)
# Handle dictionary data - recursively process each value
elif isinstance(data, dict):
return self._extract_from_dict(data, context)
# Handle list data - recursively process each element
elif isinstance(data, list):
return self._extract_from_list(data, context)
# For other types (int, bool, None, etc.), return as-is
else:
return ExtractionResult(attachments=[], sanitized_data=data)
def _extract_from_string(
self, data: str, context: Literal["input", "output", "metadata"]
) -> ExtractionResult:
"""Extract attachments from a string value."""
if len(data) < self._min_attachment_size:
# skip short strings
return ExtractionResult(attachments=[], sanitized_data=data)
attachments: List[attachment.Attachment] = []
# Rebuild positionally so each match gets its own placeholder. Using
# `str.replace` here would rewrite every occurrence of the matched chunk,
# causing later duplicate matches to alias the first one's file name.
parts: List[str] = []
last_end = 0
for match in self.pattern.finditer(data):
to_decode = match.group("base64")
decoded_attachment = self.decoder.decode(to_decode, context)
if decoded_attachment is None:
continue
attachments.append(decoded_attachment)
# The full match span covers any optional `data:<mime>;base64,` prefix,
# so replacing it with the bare placeholder strips the prefix in one go.
parts.append(data[last_end : match.start()])
parts.append(f"[{decoded_attachment.file_name}]")
last_end = match.end()
if not attachments:
return ExtractionResult(attachments=attachments, sanitized_data=data)
parts.append(data[last_end:])
return ExtractionResult(attachments=attachments, sanitized_data="".join(parts))
def _extract_from_dict(
self, data: Dict[str, Any], context: Literal["input", "output", "metadata"]
) -> ExtractionResult:
"""Recursively extract attachments from a dictionary."""
all_attachments: List[attachment.Attachment] = []
sanitized_dict = {}
for key, value in data.items():
result = self._try_extract_attachments(value, context)
sanitized_dict[key] = result.sanitized_data
all_attachments.extend(result.attachments)
return ExtractionResult(
attachments=all_attachments, sanitized_data=sanitized_dict
)
def _extract_from_list(
self, data: List[Any], context: Literal["input", "output", "metadata"]
) -> ExtractionResult:
"""Recursively extract attachments from a list."""
all_attachments: List[attachment.Attachment] = []
sanitized_list = []
for item in data:
result = self._try_extract_attachments(item, context)
sanitized_list.append(result.sanitized_data)
all_attachments.extend(result.attachments)
return ExtractionResult(
attachments=all_attachments, sanitized_data=sanitized_list
)
@@ -0,0 +1,69 @@
"""Normalize URL-safe base64 binary blobs to the standard base64 alphabet.
Some upstream SDKs (notably google.genai, which sets pydantic's
``ser_json_bytes='base64'`` on its BaseModel) emit URL-safe base64, with '-'
and '_' in place of '+' and '/'. Opik's downstream consumers — the SDK
attachments extractor in this package, and the frontend's inline image
rendering — only match the standard alphabet, so an unnormalized URL-safe blob
is silently truncated at the first '-' or '_' (see OPIK-6387).
Detection is content-based: we decode the first few bytes and require an image
file signature before rewriting, so that unrelated values which happen to
share the URL-safe alphabet (e.g. UUIDs) are left untouched.
"""
import base64
import binascii
import re
from typing import Any
from . import decoder_helpers
_URLSAFE_BASE64_RE = re.compile(r"[A-Za-z0-9_-]+={0,2}")
_MIN_BASE64_IMAGE_LENGTH = 24
def normalize_urlsafe_base64_images_in_place(node: Any) -> None:
"""Walk a nested dict/list and rewrite any URL-safe-base64-encoded image
string leaves to standard base64. Non-string leaves are ignored.
"""
if isinstance(node, dict):
for key, child in node.items():
if isinstance(child, str):
if is_urlsafe_base64_image(child):
node[key] = urlsafe_to_standard_base64(child)
else:
normalize_urlsafe_base64_images_in_place(child)
elif isinstance(node, list):
for index, item in enumerate(node):
if isinstance(item, str):
if is_urlsafe_base64_image(item):
node[index] = urlsafe_to_standard_base64(item)
else:
normalize_urlsafe_base64_images_in_place(item)
def is_urlsafe_base64_image(value: str) -> bool:
"""True if ``value`` is a URL-safe base64 encoding of an image whose
header bytes match a known signature (PNG, JPEG, GIF, WebP/RIFF, TIFF).
Returns False for strings already in the standard alphabet (no '-' or
'_'), so callers can use this as a cheap "needs rewriting?" check.
"""
if len(value) < _MIN_BASE64_IMAGE_LENGTH:
return False
if "-" not in value and "_" not in value:
return False
if not _URLSAFE_BASE64_RE.fullmatch(value):
return False
head = value[:16]
head += "=" * (-len(head) % 4)
try:
decoded = base64.urlsafe_b64decode(head)
except (binascii.Error, ValueError):
return False
return decoder_helpers.detect_image_mime_type_from_header(decoded) is not None
def urlsafe_to_standard_base64(value: str) -> str:
return value.replace("-", "+").replace("_", "/")
@@ -0,0 +1,219 @@
import base64
import logging
import os
import mimetypes
import httpx
import json.decoder
from opik import s3_httpx_client
from typing import Iterator, List, Literal, Optional
from typing_extensions import TypeAlias
from opik.file_upload import file_uploader, upload_options
from opik.rest_api import client as rest_api_client
from opik.rest_api.types import attachment as rest_api_attachment
from opik.rest_api import core as rest_api_core
from opik import url_helpers
from .. import rest_helpers
LOGGER = logging.getLogger(__name__)
RESTAttachmentDetails: TypeAlias = rest_api_attachment.Attachment
class AttachmentClient:
"""
Client for interacting with attachment-related operations.
This client provides methods to retrieve attachment lists, download attachments,
and upload attachments for traces and spans.
The AttachmentClient supports:
- Listing attachments associated with traces or spans
- Downloading attachment content as a byte stream
- Uploading files as attachments to traces or spans
All operations are performed within the context of a specific project and require
the project name to be provided.
"""
def __init__(
self,
rest_client: rest_api_client.OpikApi,
url_override: str,
workspace_name: str,
rest_httpx_client: httpx.Client,
) -> None:
"""
Initialize the AttachmentClient.
It is typically created via ``Opik.get_attachment_client()`` rather
than being instantiated directly.
Parameters:
rest_client: The REST API client instance for making backend requests.
url_override: The base URL for the Opik server.
workspace_name: The workspace name used for download operations.
rest_httpx_client: The httpx client instance to use for making file uploads.
Returns:
None
"""
self._rest_client = rest_client
self._url_override = url_override
self._workspace_name = workspace_name
self._rest_httpx_client = rest_httpx_client
def get_attachment_list(
self,
project_name: str,
entity_id: str,
entity_type: Literal["span", "trace"],
) -> List[RESTAttachmentDetails]: # type: ignore
"""
Get a list of attachments for a specific entity (trace or span).
Parameters:
project_name: The name of the project containing the entity.
entity_id: The ID of the trace or span to retrieve attachments for.
entity_type: The type of entity ("trace" or "span").
Returns:
List[RESTAttachmentDetails]: List of attachment detail objects containing metadata about each attachment.
"""
project_id = rest_helpers.resolve_project_id_by_name(
self._rest_client, project_name
)
url_override_path = base64.b64encode(self._url_override.encode("utf-8")).decode(
"utf-8"
)
response = self._rest_client.attachments.attachment_list(
project_id=project_id,
entity_type=entity_type,
entity_id=entity_id,
path=url_override_path,
)
return response.content or []
def download_attachment(
self,
project_name: str,
entity_type: Literal["trace", "span"],
entity_id: str,
file_name: str,
mime_type: str,
) -> Iterator[bytes]:
"""
Download an attachment as a stream of bytes.
Parameters:
project_name: The name of the project containing the entity.
entity_type: The type of entity ("trace" or "span").
entity_id: The ID of the trace or span containing the attachment.
file_name: The name of the file to download.
mime_type: The MIME type of the file.
Returns:
Iterator[bytes]: Iterator yielding bytes of the attachment content.
"""
attachments_details = self.get_attachment_list(
project_name=project_name,
entity_type=entity_type,
entity_id=entity_id,
)
attachment_to_download: Optional[RESTAttachmentDetails] = None
for attachment_details in attachments_details:
if (
attachment_details.file_name == file_name
and attachment_details.mime_type == mime_type
):
attachment_to_download = attachment_details
break
if attachment_to_download is None:
raise ValueError(f"Attachment not found: {file_name}")
if attachment_to_download.link is None:
raise ValueError(f"No download URL available for attachment: {file_name}")
httpx_client_upload = (
s3_httpx_client.get_cached()
if url_helpers.is_aws_presigned_url(attachment_to_download.link)
else self._rest_httpx_client
)
with httpx_client_upload.stream("GET", attachment_to_download.link) as response:
try:
if 200 <= response.status_code < 300:
for chunk in response.iter_bytes():
yield chunk
return
response.read()
response_json = response.json()
except json.decoder.JSONDecodeError:
raise rest_api_core.ApiError(
status_code=response.status_code,
headers=dict(response.headers),
body=response.text,
)
raise rest_api_core.ApiError(
status_code=response.status_code,
headers=dict(response.headers),
body=response_json,
)
def upload_attachment(
self,
project_name: str,
entity_type: Literal["trace", "span"],
entity_id: str,
file_path: str,
file_name: Optional[str] = None,
mime_type: Optional[str] = None,
) -> None:
"""
Upload an attachment for a specific entity (trace or span).
Parameters:
project_name: The name of the project containing the entity.
entity_type: The type of entity ("trace" or "span").
entity_id: The ID of the trace or span to attach the file to.
file_path: The path to the file to upload on the local filesystem.
file_name: The name to assign to the uploaded file. If not provided, uses the basename of file_path.
mime_type: The MIME type of the file. If not provided, attempts to automatically detect based on the file extension.
Returns:
None
"""
if not os.path.exists(file_path):
raise FileNotFoundError(f"File not found: {file_path}")
if file_name is None:
file_name = os.path.basename(file_path)
if mime_type is None:
mime_type, _ = mimetypes.guess_type(file_path)
file_size = os.path.getsize(file_path)
encoded_url_override = base64.b64encode(
self._url_override.encode("utf-8")
).decode("utf-8")
upload_opts = upload_options.FileUploadOptions(
file_path=file_path,
file_name=file_name,
file_size=file_size,
mime_type=mime_type,
entity_type=entity_type,
entity_id=entity_id,
project_name=project_name,
encoded_url_override=encoded_url_override,
delete_after_upload=False,
)
file_uploader.upload_attachment(
upload_options=upload_opts,
rest_client=self._rest_client,
upload_httpx_client=self._rest_httpx_client,
)
@@ -0,0 +1,164 @@
import base64
import binascii
import logging
import os
import shutil
import tempfile
from typing import Literal, Optional
from ...file_upload import mime_type
from ...message_processing import messages
from . import attachment
LOGGER = logging.getLogger(__name__)
def attachment_to_message(
attachment_data: attachment.Attachment,
entity_type: Literal["trace", "span"],
entity_id: str,
project_name: str,
url_override: str,
delete_after_upload: bool = False,
) -> messages.CreateAttachmentMessage:
if attachment_data.data is None:
raise ValueError("Attachment data cannot be None")
mimetype = guess_attachment_type(attachment_data)
base_url_path = base64.b64encode(url_override.encode("utf-8")).decode("utf-8")
if isinstance(attachment_data.data, bytes):
file_path = _write_file_like_to_temp_file(attachment_data.data)
# make sure the temporary file is deleted after upload
attachment_data.create_temp_copy = False
delete_after_upload = True
elif os.path.exists(attachment_data.data):
file_path = attachment_data.data
else:
try:
decoded_bytes = base64.b64decode(attachment_data.data, validate=True)
file_path = _write_file_like_to_temp_file(decoded_bytes)
attachment_data.create_temp_copy = False
delete_after_upload = True
except binascii.Error as e:
raise ValueError(
"Attachment data must be bytes, an existing file path, or a valid base64-encoded string."
) from e
file_name = attachment_data.file_name
should_delete_after_upload = delete_after_upload
if file_name is None:
file_name = os.path.basename(file_path)
# Try to create a temporary copy if requested
if attachment_data.create_temp_copy:
tmp_file_path = _try_create_temp_copy(file_path)
if tmp_file_path is not None:
file_path = tmp_file_path
should_delete_after_upload = True
else:
should_delete_after_upload = False
return messages.CreateAttachmentMessage(
file_path=file_path,
file_name=file_name,
mime_type=mimetype,
entity_type=entity_type,
entity_id=entity_id,
project_name=project_name,
encoded_url_override=base_url_path,
delete_after_upload=should_delete_after_upload,
)
def _write_file_like_to_temp_file(file_like: bytes) -> str:
"""
Writes a bytes-like file object to a temporary file on the filesystem.
This function accepts a file-like object in the form of bytes and writes its
contents to a temporary file. The temporary file is not deleted automatically
on program termination. The path to the created temporary file is returned.
Args:
file_like: The bytes-like object containing the file content
to be written to a temporary file.
Returns:
The full path to the created temporary file.
"""
temp_file = tempfile.NamedTemporaryFile(mode="wb", delete=False)
try:
temp_file.write(file_like)
temp_file.flush()
LOGGER.debug(
"Created temporary copy of file-like attachment to file: %s",
temp_file.name,
)
except Exception as e:
LOGGER.error(
"Failed to write file-like attachment to temp file: %s",
e,
exc_info=True,
)
raise
finally:
temp_file.close()
return temp_file.name
def _try_create_temp_copy(file_path: str) -> Optional[str]:
"""
Create a temporary copy of a file.
This ensures the file remains available for upload even if the user
deletes the original file. The temp file is created with delete=False
so it persists until the upload manager processes and deletes it.
Args:
file_path: Path to the original file.
Returns:
Path to the temporary copy.
"""
_, extension = os.path.splitext(file_path)
temp_file = tempfile.NamedTemporaryFile(mode="wb", delete=False, suffix=extension)
try:
with open(file_path, "rb") as original_file:
shutil.copyfileobj(original_file, temp_file)
temp_file.flush()
temp_file.close()
LOGGER.debug(
"Created temporary copy of attachment: %s -> %s",
file_path,
temp_file.name,
)
return temp_file.name
except Exception:
temp_file.close()
LOGGER.error(
"Failed to create temporary copy of attachment: %s. Opik will try to use the original file.",
file_path,
exc_info=True,
)
return None
def guess_attachment_type(attachment_data: attachment.Attachment) -> Optional[str]:
if attachment_data.content_type is not None:
return attachment_data.content_type
mimetype = None
if attachment_data.file_name is not None:
mimetype = mime_type.guess_mime_type(file=attachment_data.file_name)
if mimetype is not None:
return mimetype
if isinstance(attachment_data.data, str):
mimetype = mime_type.guess_mime_type(file=attachment_data.data)
elif isinstance(attachment_data.data, bytes):
mimetype = mime_type.BINARY_MIME_TYPE
return mimetype
@@ -0,0 +1,18 @@
import abc
from typing import Any, Optional
from . import attachment
class AttachmentDecoder(abc.ABC):
"""
Abstract base class for decoding file attachments.
This class serves as an interface for decoding raw attachment data into
an `Attachment` object. Implementing classes should define the specific
logic to handle various attachment decoding formats.
"""
@abc.abstractmethod
def decode(self, raw_data: str, **kwargs: Any) -> Optional[attachment.Attachment]:
pass
@@ -0,0 +1,83 @@
import base64
import binascii
import logging
import tempfile
from typing import Any, Optional, Literal
from . import attachment, decoder, decoder_helpers
LOGGER = logging.getLogger(__name__)
class Base64AttachmentDecoder(decoder.AttachmentDecoder):
"""Decodes base64 encoded attachment data.
This decoder decodes base64 strings, detects MIME types from content, and creates Attachment objects.
"""
def decode(
self,
raw_data: str,
context: Literal["input", "output", "metadata"] = "input",
**kwargs: Any,
) -> Optional[attachment.Attachment]:
"""Decode base64 encoded data into an Attachment object.
Args:
raw_data: Base64 encoded string data
context: Context string for filename generation.
Returns:
Attachment object with decoded data, or None if decoding fails or type is not recognizable
"""
if not isinstance(raw_data, str):
LOGGER.warning("Attachment data is not a string, skipping.")
return None
try:
# Decode base64 string to bytes
decoded_bytes = base64.b64decode(raw_data, validate=True)
# Detect MIME type from content
mime_type = decoder_helpers.detect_mime_type(decoded_bytes)
# Skip if not a recognizable file type
if not mime_type or mime_type in ("application/octet-stream", "text/plain"):
LOGGER.debug("Attachment type is not recognized, skipping.")
return None
# Get file extension from the MIME type
extension = decoder_helpers.get_file_extension(mime_type)
# Generate filename
file_name = decoder_helpers.create_attachment_filename(
context, extension=extension
)
# Save decoded bytes to a temporary file
temp_file = tempfile.NamedTemporaryFile(
mode="wb", delete=False, suffix=extension
)
temp_file.write(decoded_bytes)
temp_file.flush()
temp_file.close()
# Return Attachment object with a file path
return attachment.Attachment(
data=temp_file.name, file_name=file_name, content_type=mime_type
)
except (ValueError, binascii.Error) as e:
LOGGER.debug(
"Failed to decode attachment data, reason: invalid base64. Reason: %s",
e,
exc_info=True,
)
# Not valid base64, return None
return None
except Exception as ex:
LOGGER.warning(
"Failed to decode attachment data, reason: %s", ex, exc_info=True
)
# Unexpected error, return None to avoid crashing the pipeline
return None
@@ -0,0 +1,156 @@
import mimetypes
import random
import time
from typing import Optional
# The attachment file name regex
ATTACHMENT_FILE_NAME_REGEX = r"(?:input|output|metadata)-attachment-\d+-\d+-sdk\.\w+"
ATTACHMENT_FILE_NAME_PLACEHOLDER_REGEX = (
r"\[((?:input|output|metadata)-attachment-\d+-\d+-sdk\.\w+)\]"
)
def get_file_extension(mime_type: str) -> str:
"""Convert MIME type to file extension.
Mirrors the Java getFileExtension() method in AttachmentStripperService.
Args:
mime_type: The MIME type (e.g., "image/png", "application/pdf")
Returns:
File extension without a leading dot (e.g., "png", "pdf")
"""
if not mime_type:
return "bin"
# Try to get extension from mimetypes module
extension = mimetypes.guess_extension(mime_type, strict=False)
if extension:
# Remove the leading dot
extension = extension.lstrip(".")
# Handle special cases where mimetypes returns less common extensions
if mime_type == "image/jpeg" and extension == "jpe":
return "jpg"
return extension
# Fallback: extract from the MIME type (e.g., "image/png" -> "png")
if "/" in mime_type:
subtype = mime_type.split("/")[1]
# Handle special cases like "svg+xml" -> "svg"
if "+" in subtype:
subtype = subtype.split("+")[0]
# Remove any parameters (e.g., "jpeg; charset=utf-8" -> "jpeg")
subtype = subtype.split(";")[0].strip()
return subtype
return "bin"
# Image signatures keyed by header prefix. The WebP entry is handled
# separately because it lives inside a RIFF container (the "WEBP" marker is
# at offset 8, not at the start).
_IMAGE_HEADER_SIGNATURES = (
(b"\x89PNG\r\n\x1a\n", "image/png"),
(b"\xff\xd8\xff", "image/jpeg"),
(b"GIF87a", "image/gif"),
(b"GIF89a", "image/gif"),
)
def detect_image_mime_type_from_header(data: bytes) -> Optional[str]:
"""Return the image MIME type implied by the leading bytes of ``data``,
or None if no known image signature matches.
Header-only — works on partial payloads (e.g. the first 16 bytes decoded
from a base64 string). Use this when you don't have the full file and
only need to know "is this an image, and which kind?".
"""
for prefix, mime in _IMAGE_HEADER_SIGNATURES:
if data.startswith(prefix):
return mime
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return "image/webp"
return None
def detect_mime_type(data: bytes) -> Optional[str]:
"""Detect MIME type from byte content using magic bytes.
This provides basic MIME type detection similar to Apache Tika in the Java implementation.
It checks common file format magic bytes.
Args:
data: The byte data to analyze
Returns:
Detected MIME type string, or "application/octet-stream" if unknown
"""
if len(data) < 4:
return "application/octet-stream"
image_mime_type = detect_image_mime_type_from_header(data)
if image_mime_type is not None:
# Historical behavior: JPEG also requires the FFD9 end marker, so we
# only treat a payload as JPEG when the full file is intact.
if image_mime_type == "image/jpeg" and data[-2:] != b"\xff\xd9":
pass
else:
return image_mime_type
# PDF
if data[:4] == b"%PDF":
return "application/pdf"
# SVG (XML-based, check for SVG tag)
try:
text = data[:1024].decode("utf-8", errors="ignore")
if "<svg" in text.lower():
return "image/svg+xml"
except Exception:
pass
# MP4
if len(data) >= 12 and data[4:8] == b"ftyp":
return "video/mp4"
# JSON
try:
text = data[:100].decode("utf-8", errors="strict").strip()
if text.startswith("{") or text.startswith("["):
return "application/json"
except Exception:
pass
# Default to octet-stream for unknown types
return "application/octet-stream"
def create_attachment_filename(context: str, extension: str) -> str:
"""
Generates a unique attachment filename based on the provided context and file extension.
This function creates a filename by combining the given context, a randomly generated
prefix to ensure uniqueness, the current timestamp in milliseconds, and the provided
file extension. The generated filename aligns with the backend convention for naming
attachments, which includes specific formatting and structure.
Args:
context: The context to use as the base for the filename (e.g., "input",
"output", or "metadata").
extension: The file extension to use for the filename (e.g., "png",
"jpg", "txt").
Returns:
A generated filename string in the format
"{context}-attachment-{random_prefix}-{timestamp}.{extension}".
"""
# The backend has the following naming convention: r"\\[((?:input|output|metadata)-attachment-\\d+-\\d+\\.\\w+)\\]"
# Example: [input-attachment-1-1704067200000.png]
timestamp = int(round(time.time() * 1000))
# we need to generate a large enough random prefix to avoid collisions
random_prefix = random.randint(1, 99999999)
return f"{context}-attachment-{random_prefix}-{timestamp}-sdk.{extension}"
@@ -0,0 +1,412 @@
"""Connection-scoped transport resources shared by :class:`opik.Opik` handles.
The objects built here (httpx pool, REST client, message-processing chain, file
upload manager, replay manager + connection monitor, and the streamer with its
consumer threads) are properties of the *connection* ``(url, workspace, api_key,
...)`` rather than of an individual client.
Responsibilities are split so each type does one thing:
- :class:`SharedConnectionResourcesBundle` — the value object: holds the live
transport objects and knows how to dispose them (``close``).
- :class:`ConnectionResourceManager` — the lifecycle authority: derives the
connection identity, builds-or-reuses a bundle, ref-counts it, and decides
when to tear it down (including at process exit).
- :class:`Lease` — a per-handle, release-once token that delegates all lifecycle
decisions back to the manager.
"""
import atexit
import hashlib
import json
import logging
import threading
from typing import Callable, Dict, Optional, Tuple
import httpx
from .. import config as opik_config
from .. import httpx_client, rest_client_configurator
from ..file_upload import upload_manager
from ..healthcheck import connection_monitor, connection_probe
from ..message_processing import (
message_queue,
permissions,
streamer,
streamer_constructors,
)
from ..message_processing.processors import message_processors, message_processors_chain
from ..message_processing.replay import replay_manager
from ..rest_api import client as rest_api_client
LOGGER = logging.getLogger(__name__)
class SharedConnectionResourcesBundle:
"""Owns the expensive transport objects for one connection identity.
Connection-scoped: it carries no ``project_name`` or per-call state, so it
can back multiple :class:`opik.Opik` handles. ``close`` disposes what the
bundle owns — the streamer's threads and the file-upload worker pool, plus
the httpx connection pool on a durable (``flush=True``) close — so evicting a
bundle never leaks threads. ``flush_timeout`` is the connection's configured
drain budget, used when the process-exit hook closes the bundle.
"""
def __init__(
self,
httpx_client: httpx.Client,
rest_client: rest_api_client.OpikApi,
message_processor: message_processors.ChainedMessageProcessor,
file_upload_manager: upload_manager.FileUploadManager,
replay_manager: replay_manager.ReplayManager,
streamer: streamer.Streamer,
flush_timeout: Optional[int],
) -> None:
self.httpx_client = httpx_client
self.rest_client = rest_client
self.message_processor = message_processor
self.file_upload_manager = file_upload_manager
self.replay_manager = replay_manager
self.streamer = streamer
self.flush_timeout = flush_timeout
def close(self, timeout: Optional[int], *, flush: bool) -> None:
# Drain/stop the streamer (consumer threads, replay, batch preprocessor);
# on flush=True it also flushes pending file uploads.
# Closing the streamer also stops and joins the replay manager (its own
# daemon thread), so there is no separate replay teardown to do here.
self.streamer.close(timeout, flush=flush)
# Stop the upload worker pool too, so eviction doesn't leave its threads
# running. wait=flush mirrors the streamer: block for in-flight uploads
# on a durable close, return immediately on fire-and-forget teardown.
self.file_upload_manager.close(wait=flush)
if flush:
# Close the httpx pool only on a durable close, and last — after the
# streamer has joined the replay thread and uploads have drained, so
# no request is in flight. Each bundle owns a dedicated client (built
# per connection identity), so this never affects another bundle.
# flush=False is fire-and-forget: the streamer deliberately leaves
# daemon threads to finish in-flight requests, so closing the pool
# here would race them — leave it for GC / process-exit close_all.
self.httpx_client.close()
def flush(self, timeout: Optional[int]) -> None:
"""Drain the shared message queue without tearing the bundle down.
Used when a handle releases with ``flush=True`` while other handles still
share the bundle: the queued data is persisted now, but the transport
stays alive for the remaining handles.
"""
self.streamer.flush(timeout)
def _create_replay_manager(
config: opik_config.OpikConfig, httpx_client: httpx.Client
) -> replay_manager.ReplayManager:
probe = connection_probe.ConnectionProbe(
base_url=config.url_override,
client=httpx_client,
)
monitor = connection_monitor.OpikConnectionMonitor(
ping_interval=config.connection_monitor_ping_interval,
check_timeout=config.connection_monitor_check_timeout,
probe=probe,
)
return replay_manager.ReplayManager(
monitor=monitor,
batch_size=config.replay_batch_size,
batch_replay_delay=config.replay_batch_replay_delay,
tick_interval_seconds=config.replay_tick_interval,
)
def create_connection_resources(
config: opik_config.OpikConfig, *, use_batching: bool
) -> SharedConnectionResourcesBundle:
"""Build a full transport stack for ``config``.
Pure construction with no cache awareness — this is the default builder that
:class:`ConnectionResourceManager` invokes on a cache miss.
"""
httpx_client_ = httpx_client.get(
workspace=config.workspace,
api_key=config.api_key,
check_tls_certificate=config.check_tls_certificate,
compress_json_requests=config.enable_json_request_compression,
)
rest_client = rest_api_client.OpikApi(
base_url=config.url_override,
httpx_client=httpx_client_,
)
rest_client._client_wrapper._timeout = (
httpx.USE_CLIENT_DEFAULT
) # See https://github.com/fern-api/fern/issues/5321
rest_client_configurator.configure(rest_client)
max_queue_size = message_queue.calculate_max_queue_size(
maximal_queue_size=config.maximal_queue_size,
batch_factor=config.maximal_queue_size_batch_factor,
)
file_uploader = upload_manager.FileUploadManager(
rest_client=rest_client,
httpx_client=httpx_client_,
worker_count=config.file_upload_background_workers,
)
fallback_replay = _create_replay_manager(config, httpx_client_)
message_processor = message_processors_chain.create_message_processors_chain(
rest_client=rest_client,
file_upload_manager=file_uploader,
fallback_replay_manager=fallback_replay,
unauthorized_message_types_registry=permissions.UnauthorizedMessageTypeRegistry(
retry_interval_seconds=config.unauthorized_message_type_retry_interval,
max_retry_count=config.unauthorized_message_type_max_retry_count,
),
)
streamer_ = streamer_constructors.construct_online_streamer(
file_uploader=file_uploader,
n_consumers=config.background_workers,
use_batching=use_batching,
use_attachment_extraction=config.is_attachment_extraction_active,
min_base64_embedded_attachment_size=config.min_base64_embedded_attachment_size,
max_queue_size=max_queue_size,
message_processor=message_processor,
url_override=config.url_override,
fallback_replay_manager=fallback_replay,
)
return SharedConnectionResourcesBundle(
httpx_client=httpx_client_,
rest_client=rest_client,
message_processor=message_processor,
file_upload_manager=file_uploader,
replay_manager=fallback_replay,
streamer=streamer_,
flush_timeout=config.default_flush_timeout,
)
# Opaque, hashable connection identity produced by ``_connection_key``.
ConnectionKey = Tuple[str, bool]
def _connection_key(
config: opik_config.OpikConfig, *, use_batching: bool
) -> ConnectionKey:
# The whole config defines a connection's identity: any differing field
# yields a different bundle. Hashing the serialized config keeps the key
# compact and, by construction, never holds the api_key (or any field) in
# plaintext.
#
# Note this means clients that differ only by per-handle settings (e.g. a
# different default ``project_name`` or ``default_flush_timeout``) get
# separate bundles. That is safe — project is carried per trace and the flush
# timeout is a per-``end()`` argument — but to share one connection across
# projects, use a single client and pass ``project_name`` per call.
fingerprint = json.dumps(config.model_dump(mode="json"), sort_keys=True)
digest = hashlib.sha256(fingerprint.encode("utf-8")).hexdigest()
return (digest, use_batching)
class Lease:
"""Per-handle, release-once token over a bundle.
Each :class:`opik.Opik` handle holds its own lease. It carries the bundle so
the handle can delegate without re-looking it up, and guards a single
``release`` so an explicit ``end()`` followed by the GC finalizer cannot
release twice. All lifecycle *decisions* (refcount, teardown) live on the
manager — the lease only forwards.
"""
def __init__(
self,
manager: "ConnectionResourceManager",
key: ConnectionKey,
resources: SharedConnectionResourcesBundle,
) -> None:
self._manager = manager
self._key = key
self.resources = resources
self._released = False
self._once_lock = threading.Lock()
def release(
self, timeout: Optional[int], *, flush: bool = True, close_on_zero: bool
) -> None:
with self._once_lock:
if self._released:
return
self._released = True
self._manager.release(
self._key, timeout, flush=flush, close_on_zero=close_on_zero
)
class _Entry:
def __init__(
self, resources: SharedConnectionResourcesBundle, refcount: int
) -> None:
self.resources = resources
self.refcount = refcount
class ConnectionResourceManager:
"""Single owner of the shared connection-resource lifecycle.
Derives the connection identity from a config, builds-or-reuses a bundle
ref-counted by that identity, and tears a bundle down only when its last
lease is released *explicitly* (``Opik.end()``) — always after evicting it
under the lock, so a concurrent ``acquire`` never receives a closing bundle.
A reference dropped by a GC finalizer (``close_on_zero=False``) only
decrements the count and leaves the bundle cached; closing is never done in
garbage collection. Whatever survives to process exit is disposed by
``close_all``. Disposal mechanics are delegated to the bundle's ``close``;
this class owns *when* it happens.
"""
def __init__(
self,
builder: Callable[
..., SharedConnectionResourcesBundle
] = create_connection_resources,
) -> None:
self._builder = builder
self._lock = threading.Lock()
self._entries: Dict[ConnectionKey, _Entry] = {}
def acquire(
self,
config: opik_config.OpikConfig,
*,
use_batching: bool,
) -> Lease:
key = _connection_key(config, use_batching=use_batching)
# Fast path: an existing bundle is reused under the lock.
with self._lock:
entry = self._entries.get(key)
if entry is not None:
entry.refcount += 1
return Lease(manager=self, key=key, resources=entry.resources)
# No bundle yet — build outside the lock so a slow transport-stack
# construction does not serialize unrelated acquisitions.
bundle = self._builder(config, use_batching=use_batching)
with self._lock:
entry = self._entries.get(key)
if entry is None:
self._entries[key] = _Entry(resources=bundle, refcount=1)
return Lease(manager=self, key=key, resources=bundle)
# Lost the construction race: keep the bundle that won, take a
# reference on it, and drop ours below (outside the lock).
entry.refcount += 1
lease = Lease(manager=self, key=key, resources=entry.resources)
# Discard the bundle we lost the race with. A teardown failure here must
# not reject the caller — the winning lease is already valid — so log and
# move on.
try:
bundle.close(timeout=0, flush=False)
except Exception:
LOGGER.debug(
"Failed to close connection resources discarded after an acquire race",
exc_info=True,
)
return lease
def release(
self,
key: ConnectionKey,
timeout: Optional[int],
*,
flush: bool = True,
close_on_zero: bool,
) -> None:
# Durability under sharing: an explicit ``end(flush=True)`` on a handle
# that still shares its bundle must drain the shared queue *before* this
# handle gives up its reference. Flushing while our reference is still
# counted keeps refcount >= 1, so a concurrent last-release cannot evict
# and ``close(flush=False)`` the bundle — which would clear the message
# queue out from under this flush and lose the data the ``flush=True``
# caller was promised. Pre-flush only when another handle also shares the
# bundle; a sole holder's ``close(flush=True)`` below already drains
# durably. A GC finalizer (``close_on_zero=False``) never does network
# I/O, so it never pre-flushes.
if flush and close_on_zero:
with self._lock:
entry = self._entries.get(key)
shared_bundle = (
entry.resources
if entry is not None and entry.refcount > 1
else None
)
if shared_bundle is not None:
shared_bundle.flush(timeout)
# Now drop our reference. Because we only decrement here — after any
# pre-flush above has completed — a close can never run while another
# handle is mid pre-flush: that handle still holds its reference, so the
# count cannot reach zero until its flush returns.
with self._lock:
entry = self._entries.get(key)
if entry is None:
return
entry.refcount -= 1
if entry.refcount > 0:
return
if not close_on_zero:
# The last reference was dropped by a GC finalizer (see
# ``Opik._acquire_shared_resources``). Only the refcount
# decrement above is safe to run there; closing — streamer
# thread joins, file-upload pool shutdown, network flush — must
# never happen inside garbage collection. Leave the bundle
# cached so a later same-identity ``acquire`` reuses it, or
# ``close_all`` disposes it at process exit.
return
# Evict before close, under the lock, so a concurrent acquire never
# receives a bundle that is being torn down.
del self._entries[key]
bundle = entry.resources
bundle.close(timeout, flush=flush)
def close_all(self, *, flush: bool = True) -> None:
"""Close and evict every cached bundle. Registered as the process
``atexit`` hook (``flush=True``), where each bundle is drained within its
own connection's configured ``flush_timeout`` rather than unbounded;
``flush=False`` resets the registry without network I/O."""
with self._lock:
entries = list(self._entries.values())
self._entries.clear()
for entry in entries:
try:
entry.resources.close(entry.resources.flush_timeout, flush=flush)
except Exception:
LOGGER.debug(
"Failed to close shared connection resources",
exc_info=True,
)
def active_connection_count(self) -> int:
"""Number of live cached bundles. For tests and debugging."""
with self._lock:
return len(self._entries)
def reference_count(
self, config: opik_config.OpikConfig, *, use_batching: bool
) -> int:
"""Number of handles currently sharing ``config``'s bundle (0 if none)."""
key = _connection_key(config, use_batching=use_batching)
with self._lock:
entry = self._entries.get(key)
return 0 if entry is None else entry.refcount
MANAGER = ConnectionResourceManager()
atexit.register(MANAGER.close_all)
@@ -0,0 +1,10 @@
FEEDBACK_SCORE_SOURCE_SDK = "sdk"
DATASET_SOURCE_SDK = "sdk"
FEEDBACK_SCORES_MAX_BATCH_SIZE = 1000
EXPERIMENT_ITEMS_MAX_BATCH_SIZE = 1000
DATASET_ITEMS_MAX_BATCH_SIZE = 1000
ANNOTATION_QUEUE_ITEMS_MAX_BATCH_SIZE = 1000
DELETE_TRACE_BATCH_SIZE = 1000
DATASET_STREAM_BATCH_SIZE = 2000
@@ -0,0 +1,43 @@
from typing import List, Callable
from opik.rest_api import TracePublic, JsonListStringPublic
from . import conversation_thread
def create_conversation_from_traces(
traces: List[TracePublic],
input_transform: Callable[[JsonListStringPublic], str],
output_transform: Callable[[JsonListStringPublic], str],
) -> conversation_thread.ConversationThread:
"""
Creates a conversation object from given traces, transforming inputs and outputs using
provided transformation functions. The method processes each trace to compose a complete
conversation by consecutively adding user messages and assistant messages.
Args:
traces: A list of TracePublic objects representing trace data for user
and assistant interaction flows.
input_transform: A callable function that transforms the input data
from a JsonListStringPublic format to a string.
output_transform: A callable function that transforms the output data
from a JsonListStringPublic format to a string.
Returns:
A Conversation object that contains user and assistant message
sequences derived from the provided traces.
"""
# Sort traces by start time to ensure they are processed in the correct order -
# the first user message should be first recorded
traces.sort(key=lambda trace_: trace_.start_time)
discussion = conversation_thread.ConversationThread()
for trace in traces:
trace_input = input_transform(trace.input)
if trace_input is not None:
discussion.add_user_message(trace_input)
trace_output = output_transform(trace.output)
if trace_output is not None:
discussion.add_assistant_message(trace_output)
return discussion
@@ -0,0 +1,49 @@
from typing import Dict, List
import pydantic
class ConversationThreadItem(pydantic.BaseModel):
"""
Represents a single message within a conversation thread.
Each ConversationItem contains the role of the sender (e.g., 'user', 'assistant', 'system')
and the content of the message. This structured format allows for consistent representation
of messages across different conversation interfaces and evaluation systems.
"""
role: str
content: str
class ConversationThread(pydantic.BaseModel):
"""
Represents a conversation thread composed of multiple conversation items.
This class is built using Pydantic's BaseModel to ensure type validation and data
integrity. It maintains a list of conversation items, where each item is an
instance of the `ConversationThreadItem` class. The conversation thread allows
adding messages from various roles, such as assistant, user, and system, and
provides the ability to export the conversation data as a JSON-serializable list.
Attributes:
discussion (List[ConversationThreadItem]): A list of conversation items
representing the dialogue between the roles.
"""
discussion: List[ConversationThreadItem] = pydantic.Field(default_factory=list)
def add_item(self, item: ConversationThreadItem) -> None:
self.discussion.append(item)
def add_assistant_message(self, message: str) -> None:
self.add_item(ConversationThreadItem(role="assistant", content=message))
def add_user_message(self, message: str) -> None:
self.add_item(ConversationThreadItem(role="user", content=message))
def add_system_message(self, message: str) -> None:
self.add_item(ConversationThreadItem(role="system", content=message))
def as_json_list(self) -> List[Dict[str, str]]:
return [item.model_dump() for item in self.discussion]
@@ -0,0 +1,43 @@
from .dashboard import Dashboard
from .types import (
BreakdownConfig,
BreakdownField,
ChartType,
DashboardLayoutItem,
DashboardSection,
DashboardState,
DashboardType,
DashboardWidget,
ExperimentLeaderboardConfig,
ExperimentsFeedbackScoresConfig,
ProjectMetricsConfig,
ProjectMetricType,
ProjectStatsCardConfig,
StatsCardMetric,
TextMarkdownConfig,
TraceDataType,
WidgetConfig,
WidgetType,
)
__all__ = [
"Dashboard",
"BreakdownConfig",
"BreakdownField",
"ChartType",
"DashboardLayoutItem",
"DashboardSection",
"DashboardState",
"DashboardType",
"DashboardWidget",
"ExperimentLeaderboardConfig",
"ExperimentsFeedbackScoresConfig",
"ProjectMetricsConfig",
"ProjectMetricType",
"ProjectStatsCardConfig",
"StatsCardMetric",
"TextMarkdownConfig",
"TraceDataType",
"WidgetConfig",
"WidgetType",
]
@@ -0,0 +1,365 @@
import copy
import logging
from contextlib import contextmanager
from typing import TYPE_CHECKING, Any, Dict, Generator, List, Optional, Union
from opik import exceptions, id_helpers
from opik.rest_api import client as rest_api_client
from opik.rest_api.types import dashboard_public as rest_dashboard_public
from . import layout, types, validation
if TYPE_CHECKING:
from opik.api_objects import opik_client
LOGGER = logging.getLogger(__name__)
class Dashboard:
"""A high-level wrapper around an Opik dashboard.
Do not instantiate directly; use :meth:`opik.Opik.create_dashboard`,
:meth:`opik.Opik.get_dashboard`, or :meth:`opik.Opik.get_dashboards`.
The wrapper holds the raw ``config`` blob read from the backend as its source
of truth. Mutators edit that blob in place and PATCH the whole document back
(the backend replaces the column wholesale), so fields the SDK does not model
are preserved. Mutations are last-writer-wins: there is no optimistic
concurrency control, so concurrent edits to the same dashboard can clobber
each other. Call :meth:`reload` to re-sync before mutating if needed.
"""
def __init__(
self,
dashboard_public: rest_dashboard_public.DashboardPublic,
rest_client: rest_api_client.OpikApi,
client: "opik_client.Opik",
) -> None:
self._rest_client = rest_client
self.client = client
self._absorb(dashboard_public)
@classmethod
def from_public(
cls,
dashboard_public: rest_dashboard_public.DashboardPublic,
rest_client: rest_api_client.OpikApi,
client: "opik_client.Opik",
) -> "Dashboard":
return cls(
dashboard_public=dashboard_public, rest_client=rest_client, client=client
)
def _absorb(self, dashboard_public: rest_dashboard_public.DashboardPublic) -> None:
if dashboard_public.id is None:
raise exceptions.DashboardValidationError(
"Backend returned a dashboard without an id"
)
self._id: str = dashboard_public.id
self._name = dashboard_public.name
self._description = dashboard_public.description
self._type = dashboard_public.type
self._scope = dashboard_public.scope
self._project_id: Optional[str] = dashboard_public.project_id
self._config: Dict[str, Any] = copy.deepcopy(dashboard_public.config or {})
self._created_at = dashboard_public.created_at
self._last_updated_at = dashboard_public.last_updated_at
@property
def id(self) -> str:
"""Unique identifier of the dashboard."""
return self._id
@property
def name(self) -> str:
"""Display name shown in the Opik UI."""
return self._name
@property
def description(self) -> Optional[str]:
"""Optional free-text description of the dashboard's purpose."""
return self._description
@property
def type(self) -> Optional[str]:
"""Dashboard type: ``"multi_project"`` for project-metric charts,
or ``"experiments"`` for experiment evaluation charts.
The type constrains which widget types are allowed (see
:class:`~opik.api_objects.dashboard.types.WidgetType`).
"""
return self._type
@property
def project_id(self) -> Optional[str]:
"""Project this dashboard is scoped to, or ``None`` for workspace-level dashboards.
Project-scoped widget types (``project_metrics``, ``project_stats_card``) can
only be added to dashboards that have a project. Pass ``project_name`` or
``project_id`` to :meth:`opik.Opik.create_dashboard` to associate a project.
"""
return self._project_id
@property
def scope(self) -> Optional[str]:
"""Visibility scope set by the backend: ``"workspace"`` (shared with all
workspace members) or ``"insights"`` (platform-managed insight dashboard).
"""
return self._scope
@property
def config(self) -> Dict[str, Any]:
"""A deep copy of the raw JSON config blob persisted by the backend.
The schema is owned by the frontend; use :attr:`sections` for a typed view.
Direct mutation of this copy has no effect — use the mutator methods instead.
"""
return copy.deepcopy(self._config)
@property
def state(self) -> types.DashboardState:
"""The config parsed into a typed :class:`~opik.api_objects.dashboard.types.DashboardState`
(version, sections, lastModified). Returns a new object on every access.
"""
return types.DashboardState.model_validate(self._config)
@property
def sections(self) -> List[types.DashboardSection]:
"""Typed sections of the dashboard, each containing widgets and their grid layout.
Returns a new list on every access; mutations do not affect the dashboard — call
:meth:`replace_sections` to persist changes.
"""
return self.state.sections
def add_widget(
self,
widget: Union[types.DashboardWidget, Dict[str, Any]],
*,
section_id: Optional[str] = None,
size: Optional[Dict[str, int]] = None,
) -> str:
"""Add a widget to a section and auto-place it on the grid.
The widget is positioned using the same algorithm as the Opik UI, appending
it to the first available slot that fits its default (or overridden) size.
Args:
widget: A :class:`~opik.api_objects.dashboard.types.DashboardWidget`
instance or a raw config dict.
section_id: ID of the section to add the widget to. Defaults to the
first section on the dashboard.
size: Optional ``{"w": int, "h": int}`` to override the widget's
default grid dimensions (columns × rows).
Returns:
The ID of the newly added widget.
"""
self._assert_config_writable()
widget_dict = copy.deepcopy(validation.as_widget_dict(widget))
if not widget_dict.get("id"):
widget_dict["id"] = id_helpers.generate_id()
if size is not None and ("w" not in size or "h" not in size):
raise exceptions.DashboardValidationError(
"size must contain both 'w' and 'h' keys"
)
validation.validate_widget_for_dashboard(widget_dict, self._type)
validation.inject_project_id(widget_dict, self._project_id)
resolved_section_id = (
self._default_section_id() if section_id is None else section_id
)
with self._atomic_config():
section = self._get_section(resolved_section_id)
section.setdefault("widgets", []).append(widget_dict)
typed_layout = [
types.DashboardLayoutItem.model_validate(i)
for i in section.get("layout", [])
]
section["layout"] = [
item.to_jsonable()
for item in layout.calculate_layout_for_adding_widget(
typed_layout,
widget_type=str(widget_dict["type"]),
widget_id=str(widget_dict["id"]),
size=size,
)
]
self._commit_config()
return str(widget_dict["id"])
def remove_widget(self, widget_id: str) -> None:
"""Remove a widget and its grid layout entry from whichever section holds it.
Raises :class:`~opik.exceptions.DashboardValidationError` if the widget ID
is not found.
"""
self._assert_config_writable()
removed = False
for section in self._config.get("sections", []):
widgets = section.get("widgets", [])
kept = [w for w in widgets if w.get("id") != widget_id]
if len(kept) != len(widgets):
removed = True
if not removed:
raise exceptions.DashboardValidationError(
f"Widget with id {widget_id!r} not found in dashboard"
)
with self._atomic_config():
for section in self._config.get("sections", []):
widgets = section.get("widgets", [])
kept = [w for w in widgets if w.get("id") != widget_id]
if len(kept) != len(widgets):
section["widgets"] = kept
typed_layout = [
types.DashboardLayoutItem.model_validate(i)
for i in section.get("layout", [])
]
section["layout"] = [
item.to_jsonable()
for item in layout.remove_widget_from_layout(
typed_layout, widget_id
)
]
self._commit_config()
def update_widget(
self,
widget_id: str,
*,
title: Optional[str] = None,
subtitle: Optional[str] = None,
config: Optional[Union[types.WidgetConfig, Dict[str, Any]]] = None,
) -> None:
"""Update a widget's display properties or configuration.
Only the supplied keyword arguments are changed; omitted ones are left
as-is. ``config`` is *merged* into the widget's existing config dict
(not replaced), so partial updates are safe.
"""
self._assert_config_writable()
with self._atomic_config():
widget = self._find_widget(widget_id)
if title is not None:
widget["title"] = title
if subtitle is not None:
widget["subtitle"] = subtitle
if config is not None:
config_dict = (
config.to_jsonable()
if isinstance(config, types._DashboardModel)
else config
)
if not isinstance(widget.get("config"), dict):
widget["config"] = {}
widget["config"].update(config_dict)
validation.validate_widget_for_dashboard(widget, self._type)
self._commit_config()
def add_section(self, title: str) -> str:
"""Append a new empty section to the dashboard and return its ID.
Sections group related widgets visually; each dashboard starts with one
default section created by the backend.
"""
self._assert_config_writable()
section = types.DashboardSection(title=title).to_jsonable()
with self._atomic_config():
self._config.setdefault("sections", []).append(section)
self._commit_config()
return str(section["id"])
def replace_sections(
self,
sections: List[Union[types.DashboardSection, Dict[str, Any]]],
) -> None:
"""Replace all sections of the dashboard at once.
This is the primary way to reorder sections, move widgets between sections,
or adjust widget grid positions (x, y, w, h) — mutate the list returned by
:attr:`sections` and pass it back here.
"""
self._assert_config_writable()
section_dicts = copy.deepcopy(validation.as_section_dicts(sections))
for section in section_dicts:
for widget in section.get("widgets", []):
validation.validate_widget_for_dashboard(widget, self._type)
validation.inject_project_id(widget, self._project_id)
with self._atomic_config():
self._config["sections"] = section_dicts
self._commit_config()
def rename(self, name: str) -> None:
"""Change the dashboard's display name."""
response = self._rest_client.dashboards.update_dashboard(self._id, name=name)
self._absorb(response)
def set_description(self, description: str) -> None:
"""Set the dashboard's free-text description."""
response = self._rest_client.dashboards.update_dashboard(
self._id, description=description
)
self._absorb(response)
def reload(self) -> None:
"""Re-fetch the dashboard from the backend, replacing all local state.
Useful before a mutating operation when another client may have modified
the dashboard since it was last loaded (last-writer-wins semantics).
"""
response = self._rest_client.dashboards.get_dashboard_by_id(self._id)
self._absorb(response)
def delete(self) -> None:
"""Permanently delete the dashboard from the workspace."""
self._rest_client.dashboards.delete_dashboard(self._id)
@contextmanager
def _atomic_config(self) -> Generator[None, None, None]:
"""Snapshot _config before a mutation; restore it if the commit fails."""
old_config = copy.deepcopy(self._config)
try:
yield
except Exception:
self._config = old_config
raise
def _assert_config_writable(self) -> None:
validation.validate_writable_version(self._config.get("version"))
def _commit_config(self) -> None:
self._config["version"] = types.DASHBOARD_VERSION
self._config["lastModified"] = types.now_ms()
validation.validate_structure(self._config)
response = self._rest_client.dashboards.update_dashboard(
self._id, config=self._config
)
self._absorb(response)
def _default_section_id(self) -> str:
sections = self._config.get("sections", [])
if not sections:
raise exceptions.DashboardValidationError(
"Dashboard has no sections. Add a section first with add_section()."
)
return str(sections[0]["id"])
def _get_section(self, section_id: str) -> Dict[str, Any]:
for section in self._config.get("sections", []):
if section.get("id") == section_id:
return section
raise exceptions.DashboardValidationError(
f"Section with id {section_id!r} not found in dashboard"
)
def _find_widget(self, widget_id: str) -> Dict[str, Any]:
for section in self._config.get("sections", []):
for widget in section.get("widgets", []):
if widget.get("id") == widget_id:
return widget
raise exceptions.DashboardValidationError(
f"Widget with id {widget_id!r} not found in dashboard"
)
@@ -0,0 +1,152 @@
"""Grid auto-layout, ported from apps/opik-frontend/src/lib/dashboard/layout.ts.
Operates on :class:`~opik.api_objects.dashboard.types.DashboardLayoutItem` objects.
Keeping the algorithm in sync with the frontend means widgets the SDK adds are
positioned the same way the UI would position them.
"""
from typing import Any, Dict, List, Optional
from . import types
from .types import DashboardLayoutItem
GRID_COLUMNS = types.GRID_COLUMNS
MAX_WIDGET_HEIGHT = types.MAX_WIDGET_HEIGHT
MIN_WIDGET_WIDTH = types.MIN_WIDGET_WIDTH
MIN_WIDGET_HEIGHT = types.MIN_WIDGET_HEIGHT
_WIDGET_SIZE_CONFIG: Dict[str, Dict[str, int]] = {
types.WidgetType.PROJECT_METRICS.value: {"w": 2, "h": 4, "minW": 2, "minH": 4},
types.WidgetType.PROJECT_STATS_CARD.value: {"w": 1, "h": 2, "minW": 1, "minH": 2},
types.WidgetType.TEXT_MARKDOWN.value: {"w": 2, "h": 4, "minW": 1, "minH": 4},
types.WidgetType.EXPERIMENTS_FEEDBACK_SCORES.value: {
"w": 2,
"h": 4,
"minW": 2,
"minH": 4,
},
types.WidgetType.EXPERIMENT_LEADERBOARD.value: {
"w": 6,
"h": 6,
"minW": 4,
"minH": 4,
},
}
_DEFAULT_SIZE_CONFIG = {
"w": 2,
"h": 2,
"minW": MIN_WIDGET_WIDTH,
"minH": MIN_WIDGET_HEIGHT,
}
def get_widget_size_config(widget_type: str) -> Dict[str, int]:
return _WIDGET_SIZE_CONFIG.get(widget_type, _DEFAULT_SIZE_CONFIG)
def get_column_heights(layout: List[DashboardLayoutItem]) -> List[int]:
heights = [0] * GRID_COLUMNS
for item in layout:
start_col = item.x
end_col = min(item.x + item.w, GRID_COLUMNS)
item_bottom = item.y + item.h
for col in range(start_col, end_col):
heights[col] = max(heights[col], item_bottom)
return heights
def find_first_available_position(
w: int, h: int, column_heights: List[int]
) -> Dict[str, int]:
# The frontend leaves w unclamped, which makes the search range empty (and y
# Infinity) for w > GRID_COLUMNS. Clamp here so the result is always a finite
# placement; real widget sizes never exceed the grid width.
w = min(w, GRID_COLUMNS)
min_height = float("inf")
best_x = 0
for x in range(0, GRID_COLUMNS - w + 1):
max_height_in_range = max(column_heights[x : x + w])
if max_height_in_range < min_height:
min_height = max_height_in_range
best_x = x
return {"x": best_x, "y": int(min_height)}
def calculate_layout_for_adding_widget(
layout: List[DashboardLayoutItem],
widget_type: str,
widget_id: str,
size: Optional[Dict[str, int]] = None,
) -> List[DashboardLayoutItem]:
size_config = get_widget_size_config(widget_type)
raw_w = size["w"] if size else size_config["w"]
raw_h = size["h"] if size else size_config["h"]
w = max(size_config["minW"], min(raw_w, GRID_COLUMNS))
h = max(size_config["minH"], min(raw_h, MAX_WIDGET_HEIGHT))
new_item = DashboardLayoutItem(
id=widget_id,
x=0,
y=0,
w=w,
h=h,
min_w=size_config["minW"],
min_h=size_config["minH"],
max_w=GRID_COLUMNS,
max_h=MAX_WIDGET_HEIGHT,
)
if not layout:
return [new_item]
column_heights = get_column_heights(layout)
position = find_first_available_position(w, h, column_heights)
new_item.x = position["x"]
new_item.y = position["y"]
return [*layout, new_item]
def normalize_layout(
layout: List[DashboardLayoutItem],
widgets: Optional[List[Dict[str, Any]]] = None,
) -> List[DashboardLayoutItem]:
widgets_by_id = {w["id"]: w for w in (widgets or [])}
normalized: List[DashboardLayoutItem] = []
for item in layout:
widget = widgets_by_id.get(item.id)
if widget is not None:
size_config = get_widget_size_config(str(widget["type"]))
min_w, min_h = size_config["minW"], size_config["minH"]
else:
min_w, min_h = MIN_WIDGET_WIDTH, MIN_WIDGET_HEIGHT
normalized.append(
DashboardLayoutItem(
id=item.id,
x=max(0, min(item.x, GRID_COLUMNS - item.w)),
y=max(0, item.y),
w=max(min_w, min(item.w, GRID_COLUMNS)),
h=max(min_h, min(item.h, MAX_WIDGET_HEIGHT)),
min_w=min_w,
min_h=min_h,
max_w=GRID_COLUMNS,
max_h=MAX_WIDGET_HEIGHT,
)
)
return normalized
def remove_widget_from_layout(
layout: List[DashboardLayoutItem], widget_id: str
) -> List[DashboardLayoutItem]:
return [item for item in layout if item.id != widget_id]
@@ -0,0 +1,53 @@
from typing import TYPE_CHECKING, List, Optional
from opik.rest_api import client as rest_api_client
from . import dashboard
if TYPE_CHECKING:
from opik.api_objects import opik_client
_PAGE_SIZE = 100
def find_dashboards(
rest_client: rest_api_client.OpikApi,
client: "opik_client.Opik",
name: Optional[str] = None,
project_id: Optional[str] = None,
max_results: int = 100,
sorting: Optional[str] = None,
filters: Optional[str] = None,
) -> List[dashboard.Dashboard]:
dashboards: List[dashboard.Dashboard] = []
page = 1
while len(dashboards) < max_results:
page_data = rest_client.dashboards.find_dashboards(
page=page,
size=_PAGE_SIZE,
name=name,
project_id=project_id,
sorting=sorting,
filters=filters,
)
content = page_data.content or []
if not content:
break
for dashboard_public in content[: max_results - len(dashboards)]:
dashboards.append(
dashboard.Dashboard.from_public(
dashboard_public=dashboard_public,
rest_client=rest_client,
client=client,
)
)
if len(content) < _PAGE_SIZE:
break
page += 1
return dashboards
@@ -0,0 +1,296 @@
"""Typed models for the Opik dashboard ``config`` blob.
The dashboard ``config`` is stored by the backend as an opaque JSON document; its
real schema is owned by the frontend. These models mirror that schema (schema
``version`` 4) and are used purely as builders/validators — the canonical
representation carried by :class:`opik.api_objects.dashboard.dashboard.Dashboard`
is always the raw ``dict`` read back from the backend, so fields the SDK does not
model survive a read-modify-write round trip untouched.
Source of truth, re-sync these when the frontend schema changes:
- apps/opik-frontend/src/types/dashboard.ts (state/section/widget/layout shapes, enums)
- apps/opik-frontend/src/lib/dashboard/utils.ts (DASHBOARD_VERSION, experiment limits, widget/type rules)
- apps/opik-frontend/src/lib/dashboard/layout.ts (grid constants, per-widget sizes)
- apps/opik-frontend/src/api/projects/useProjectMetric.ts (ProjectMetricType wire values)
- apps/opik-frontend/src/v2/pages-shared/dashboards/widgets/ProjectStatsCardWidget/metrics.ts (StatsCardMetric ids)
Enums are permissive: known values are exposed for discoverability/autocomplete,
but the model fields accept arbitrary strings so the SDK keeps working when the
frontend adds new widget types, metrics, or fields.
"""
import enum
import time
from typing import Any, Dict, List, Optional, Union
import pydantic
from pydantic.alias_generators import to_camel
from opik import exceptions, id_helpers
# Mirrors apps/opik-frontend/src/lib/dashboard/utils.ts and layout.ts
DASHBOARD_VERSION = 4
GRID_COLUMNS = 6
MAX_WIDGET_HEIGHT = 12
MIN_WIDGET_WIDTH = 1
MIN_WIDGET_HEIGHT = 1
MIN_MAX_EXPERIMENTS = 1
MAX_MAX_EXPERIMENTS = 100
DEFAULT_MAX_EXPERIMENTS = 10
FEEDBACK_SCORES_PREFIX = "feedback_scores."
def now_ms() -> int:
"""Current epoch time in milliseconds (frontend stores ``lastModified`` as ms)."""
return int(time.time() * 1000)
class DashboardType(str, enum.Enum):
MULTI_PROJECT = "multi_project"
EXPERIMENTS = "experiments"
class WidgetType(str, enum.Enum):
PROJECT_METRICS = "project_metrics"
PROJECT_STATS_CARD = "project_stats_card"
TEXT_MARKDOWN = "text_markdown"
EXPERIMENTS_FEEDBACK_SCORES = "experiments_feedback_scores"
EXPERIMENT_LEADERBOARD = "experiment_leaderboard"
class BreakdownField(str, enum.Enum):
NONE = "none"
TAGS = "tags"
METADATA = "metadata"
NAME = "name"
ERROR_INFO = "error_info"
ERROR_TYPE = "error_type"
MODEL = "model"
PROVIDER = "provider"
TYPE = "type"
class ChartType(str, enum.Enum):
LINE = "line"
BAR = "bar"
RADAR = "radar"
class TraceDataType(str, enum.Enum):
TRACES = "traces"
SPANS = "spans"
class ProjectMetricType(str, enum.Enum):
"""ALL-CAPS metric ids used by the ``project_metrics`` widget ``metricType`` field."""
FEEDBACK_SCORES = "FEEDBACK_SCORES"
TRACE_COUNT = "TRACE_COUNT"
DURATION = "DURATION"
TOKEN_USAGE = "TOKEN_USAGE"
COST = "COST"
GUARDRAILS_FAILED_COUNT = "GUARDRAILS_FAILED_COUNT"
THREAD_COUNT = "THREAD_COUNT"
THREAD_DURATION = "THREAD_DURATION"
THREAD_FEEDBACK_SCORES = "THREAD_FEEDBACK_SCORES"
SPAN_COUNT = "SPAN_COUNT"
SPAN_DURATION = "SPAN_DURATION"
SPAN_FEEDBACK_SCORES = "SPAN_FEEDBACK_SCORES"
SPAN_TOKEN_USAGE = "SPAN_TOKEN_USAGE"
TRACE_AVERAGE_DURATION = "TRACE_AVERAGE_DURATION"
SPAN_AVERAGE_DURATION = "SPAN_AVERAGE_DURATION"
THREAD_AVERAGE_DURATION = "THREAD_AVERAGE_DURATION"
TRACE_ERROR_RATE = "TRACE_ERROR_RATE"
SPAN_ERROR_RATE = "SPAN_ERROR_RATE"
class StatsCardMetric(str, enum.Enum):
"""Lowercase-dotted metric ids used by the ``project_stats_card`` widget ``metric`` field.
Dynamic feedback-score metrics use the ``feedback_scores.<score_name>`` form and
are not enumerated here; any such string is accepted.
"""
DURATION_P50 = "duration.p50"
DURATION_P90 = "duration.p90"
DURATION_P99 = "duration.p99"
INPUT = "input"
OUTPUT = "output"
METADATA = "metadata"
TAGS = "tags"
TOTAL_ESTIMATED_COST_SUM = "total_estimated_cost_sum"
USAGE_COMPLETION_TOKENS = "usage.completion_tokens"
USAGE_PROMPT_TOKENS = "usage.prompt_tokens"
USAGE_TOTAL_TOKENS = "usage.total_tokens"
ERROR_COUNT = "error_count"
TRACE_COUNT = "trace_count"
THREAD_COUNT = "thread_count"
LLM_SPAN_COUNT = "llm_span_count"
SPAN_COUNT = "span_count"
TOTAL_ESTIMATED_COST = "total_estimated_cost"
GUARDRAILS_FAILED_COUNT = "guardrails_failed_count"
class _DashboardModel(pydantic.BaseModel):
"""Base for every dashboard model: snake_case in Python, camelCase on the wire."""
model_config = pydantic.ConfigDict(
alias_generator=to_camel,
populate_by_name=True,
extra="allow",
)
def to_jsonable(self) -> Dict[str, Any]:
"""Serialize to a pure-JSON dict with camelCase keys (drops unset optionals)."""
return self.model_dump(by_alias=True, exclude_none=True, mode="json")
class BreakdownConfig(_DashboardModel):
field: str
metadata_key: Optional[str] = None
sub_metric: Optional[str] = None
aggregate_total: Optional[bool] = None
@pydantic.model_validator(mode="after")
def _check_metadata_key(self) -> "BreakdownConfig":
if self.field == BreakdownField.METADATA and not self.metadata_key:
raise exceptions.DashboardValidationError(
"breakdown.metadata_key is required when breakdown.field is 'metadata'"
)
return self
class ProjectMetricsConfig(_DashboardModel):
metric_type: str = ProjectMetricType.TRACE_COUNT.value
chart_type: Optional[str] = ChartType.LINE.value
trace_filters: Optional[List[Dict[str, Any]]] = None
thread_filters: Optional[List[Dict[str, Any]]] = None
span_filters: Optional[List[Dict[str, Any]]] = None
feedback_scores: Optional[List[str]] = None
duration_metrics: Optional[List[str]] = None
usage_metrics: Optional[List[str]] = None
breakdown: Optional[BreakdownConfig] = None
class ProjectStatsCardConfig(_DashboardModel):
source: str = TraceDataType.TRACES.value
metric: str = StatsCardMetric.TRACE_COUNT.value
trace_filters: Optional[List[Dict[str, Any]]] = None
span_filters: Optional[List[Dict[str, Any]]] = None
class TextMarkdownConfig(_DashboardModel):
content: Optional[str] = None
class ExperimentsFeedbackScoresConfig(_DashboardModel):
filters: Optional[List[Dict[str, Any]]] = None
groups: Optional[List[Dict[str, Any]]] = None
chart_type: Optional[str] = ChartType.BAR.value
feedback_scores: Optional[List[str]] = None
max_experiments_count: Optional[Union[int, str]] = DEFAULT_MAX_EXPERIMENTS
@pydantic.model_validator(mode="after")
def _check_max_experiments(self) -> "ExperimentsFeedbackScoresConfig":
_check_experiment_range(self.max_experiments_count, "max_experiments_count")
return self
class ExperimentLeaderboardConfig(_DashboardModel):
selected_columns: List[str] = pydantic.Field(
default_factory=lambda: [
"dataset_id",
"created_at",
"duration.p50",
"pass_rate",
]
)
enable_ranking: bool = False
filters: Optional[List[Dict[str, Any]]] = None
ranking_metric: Optional[str] = None
ranking_direction: Optional[bool] = None
columns_order: Optional[List[str]] = None
scores_columns_order: Optional[List[str]] = None
metadata_columns_order: Optional[List[str]] = None
columns_width: Optional[Dict[str, int]] = None
# Frontend stores this as either an int (default config) or a string (after editing).
max_rows: Optional[Union[int, str]] = None
sorting: Optional[List[Dict[str, Any]]] = None
@pydantic.model_validator(mode="after")
def _check_ranking_and_rows(self) -> "ExperimentLeaderboardConfig":
if self.enable_ranking and not self.ranking_metric:
raise exceptions.DashboardValidationError(
"ranking_metric is required when enable_ranking is True"
)
if self.max_rows is not None:
_check_experiment_range(self.max_rows, "max_rows")
return self
def _check_experiment_range(value: Union[int, str, None], field_name: str) -> None:
if value is None:
return
try:
numeric = int(value)
except (TypeError, ValueError):
raise exceptions.DashboardValidationError(
f"{field_name} must be an integer between {MIN_MAX_EXPERIMENTS} and {MAX_MAX_EXPERIMENTS}"
)
if not MIN_MAX_EXPERIMENTS <= numeric <= MAX_MAX_EXPERIMENTS:
raise exceptions.DashboardValidationError(
f"{field_name} must be between {MIN_MAX_EXPERIMENTS} and {MAX_MAX_EXPERIMENTS}, got {numeric}"
)
WidgetConfig = Union[
ProjectMetricsConfig,
ProjectStatsCardConfig,
TextMarkdownConfig,
ExperimentsFeedbackScoresConfig,
ExperimentLeaderboardConfig,
]
class DashboardWidget(_DashboardModel):
type: str
id: str = pydantic.Field(default_factory=id_helpers.generate_id)
title: str = ""
generated_title: Optional[str] = None
subtitle: Optional[str] = None
config: Dict[str, Any] = pydantic.Field(default_factory=dict)
@pydantic.field_validator("config", mode="before")
@classmethod
def _normalize_config(cls, value: Any) -> Dict[str, Any]:
if isinstance(value, _DashboardModel):
return value.to_jsonable()
if value is None:
return {}
return value
class DashboardLayoutItem(_DashboardModel):
id: str = pydantic.Field(alias="i")
x: int
y: int
w: int
h: int
min_w: Optional[int] = None
max_w: Optional[int] = None
min_h: Optional[int] = None
max_h: Optional[int] = None
class DashboardSection(_DashboardModel):
title: str
id: str = pydantic.Field(default_factory=id_helpers.generate_id)
widgets: List[DashboardWidget] = pydantic.Field(default_factory=list)
layout: List[DashboardLayoutItem] = pydantic.Field(default_factory=list)
class DashboardState(_DashboardModel):
version: int = DASHBOARD_VERSION
sections: List[DashboardSection] = pydantic.Field(default_factory=list)
last_modified: int = pydantic.Field(default_factory=now_ms)
@@ -0,0 +1,177 @@
"""Dashboard validation invariants.
Structural invariants (always enforced, on every write) keep the config in a
state the frontend can render. Semantic checks (enforced only when constructing
or mutating a widget) mirror the frontend's product rules. Reads never validate —
the SDK must always be able to load dashboards created by a newer frontend.
"""
import logging
from typing import Any, Dict, List, Optional, Union
from opik import exceptions
from . import types
LOGGER = logging.getLogger(__name__)
_WIDGET_TYPES_BY_DASHBOARD_TYPE = {
types.DashboardType.MULTI_PROJECT.value: {
types.WidgetType.PROJECT_METRICS.value,
types.WidgetType.PROJECT_STATS_CARD.value,
types.WidgetType.TEXT_MARKDOWN.value,
},
types.DashboardType.EXPERIMENTS.value: {
types.WidgetType.EXPERIMENTS_FEEDBACK_SCORES.value,
types.WidgetType.EXPERIMENT_LEADERBOARD.value,
types.WidgetType.TEXT_MARKDOWN.value,
},
}
_PROJECT_METRIC_TYPES = {m.value for m in types.ProjectMetricType}
_STATS_CARD_METRICS = {m.value for m in types.StatsCardMetric}
_PROJECT_SCOPED_WIDGET_TYPES = {
types.WidgetType.PROJECT_METRICS.value,
types.WidgetType.PROJECT_STATS_CARD.value,
}
def inject_project_id(widget_dict: Dict[str, Any], project_id: Optional[str]) -> None:
"""Inject projectId into a project-scoped widget config.
Raises DashboardValidationError if the widget is project-scoped but the dashboard
has no project_id — callers should ensure the dashboard was created with a project.
"""
if widget_dict.get("type") not in _PROJECT_SCOPED_WIDGET_TYPES:
return
if not project_id:
raise exceptions.DashboardValidationError(
f"Widget type {widget_dict.get('type')!r} requires a project-scoped dashboard. "
"Pass project_name or project_id to create_dashboard."
)
widget_dict.setdefault("config", {})["projectId"] = project_id
def validate_structure(state: Dict[str, Any]) -> None:
"""Enforce widget/layout cross-references and id uniqueness across the dashboard."""
seen_section_ids: set = set()
seen_widget_ids: set = set()
for section in state.get("sections", []):
section_id = section.get("id")
if section_id in seen_section_ids:
raise exceptions.DashboardValidationError(
f"Duplicate section id: {section_id!r}"
)
seen_section_ids.add(section_id)
widget_ids = []
for widget in section.get("widgets", []):
widget_id = widget.get("id")
if widget_id in seen_widget_ids:
raise exceptions.DashboardValidationError(
f"Duplicate widget id: {widget_id!r}"
)
seen_widget_ids.add(widget_id)
widget_ids.append(widget_id)
widget_id_set = set(widget_ids)
layout_ids = [item.get("i") for item in section.get("layout", [])]
layout_id_set = set(layout_ids)
missing_layout = widget_id_set - layout_id_set
if missing_layout:
raise exceptions.DashboardValidationError(
f"Widgets without a layout item in section {section_id!r}: {missing_layout}"
)
orphan_layout = layout_id_set - widget_id_set
if orphan_layout:
raise exceptions.DashboardValidationError(
f"Layout items referencing missing widgets in section {section_id!r}: {orphan_layout}"
)
def validate_widget_for_dashboard(
widget: Dict[str, Any], dashboard_type: Optional[str]
) -> None:
"""Construct-time semantic checks for a single widget being added/updated."""
widget_type = widget.get("type")
if dashboard_type is not None:
allowed = _WIDGET_TYPES_BY_DASHBOARD_TYPE.get(dashboard_type)
if allowed is not None and widget_type not in allowed:
raise exceptions.DashboardValidationError(
f"Widget type {widget_type!r} is not supported on a {dashboard_type!r} "
f"dashboard. Allowed types: {sorted(allowed)}"
)
_warn_on_unknown_metric(widget_type, widget.get("config", {}))
def _warn_on_unknown_metric(widget_type: Optional[str], config: Dict[str, Any]) -> None:
if widget_type == types.WidgetType.PROJECT_METRICS.value:
metric = config.get("metricType")
if metric is not None and metric not in _PROJECT_METRIC_TYPES:
LOGGER.warning(
"Unknown project_metrics metricType %r. Expected one of the ALL-CAPS "
"ids in opik.dashboard.ProjectMetricType (e.g. 'TRACE_COUNT', 'DURATION').",
metric,
)
elif widget_type == types.WidgetType.PROJECT_STATS_CARD.value:
metric = config.get("metric")
if (
metric is not None
and metric not in _STATS_CARD_METRICS
and not str(metric).startswith(types.FEEDBACK_SCORES_PREFIX)
):
LOGGER.warning(
"Unknown project_stats_card metric %r. Expected one of the "
"lowercase-dotted ids in opik.dashboard.StatsCardMetric (e.g. "
"'trace_count', 'duration.p50') or a 'feedback_scores.<name>' id.",
metric,
)
def validate_writable_version(version: Optional[int]) -> None:
"""Refuse to write back a config whose schema version the SDK does not know.
The SDK does not run the frontend migration chain, so re-stamping a config of
an unknown version risks silently corrupting it.
"""
if version is not None and version != types.DASHBOARD_VERSION:
raise exceptions.DashboardValidationError(
f"Refusing to write a dashboard with schema version {version}; this SDK "
f"only understands version {types.DASHBOARD_VERSION}. Upgrade the Opik SDK "
f"to modify this dashboard."
)
def as_widget_dict(
widget: Union[types.DashboardWidget, Dict[str, Any]],
) -> Dict[str, Any]:
"""Coerce a DashboardWidget model or a raw dict into a plain config dict."""
if isinstance(widget, types.DashboardWidget):
return widget.to_jsonable()
if isinstance(widget, dict):
return widget
raise exceptions.DashboardValidationError(
f"Expected a DashboardWidget or dict, got {type(widget).__name__}"
)
def as_section_dicts(
sections: List[Union[types.DashboardSection, Dict[str, Any]]],
) -> List[Dict[str, Any]]:
"""Coerce a list of DashboardSection models or raw dicts into plain dicts."""
result: List[Dict[str, Any]] = []
for section in sections:
if isinstance(section, types.DashboardSection):
result.append(section.to_jsonable())
elif isinstance(section, dict):
result.append(section)
else:
raise exceptions.DashboardValidationError(
f"Expected a DashboardSection or dict, got {type(section).__name__}"
)
return result
@@ -0,0 +1,79 @@
from typing import Optional, Any, Dict, List, Union
import pydantic
from opik import dict_utils
def merge_tags(
existing_tags: Optional[List[str]], new_tags: Optional[List[str]]
) -> Optional[List[str]]:
"""Merge tag lists, preserving existing tags and adding new ones.
If both existing_tags and new_tags are None or empty, return None."""
if existing_tags is None and new_tags is None:
return None
result = list(existing_tags or [])
if new_tags:
for tag in new_tags:
if tag not in result:
result.append(tag)
return result if result else None
def merge_metadata(
existing_metadata: Optional[Dict[str, Any]],
new_metadata: Optional[Union[Dict[str, Any], pydantic.BaseModel]],
prompts: Optional[List[Dict[str, Any]]] = None,
) -> Optional[Dict[str, Any]]:
"""Merge the existing metadata dictionary with new data, with new values taking precedence.
If both existing_metadata and new_metadata are None or empty, return None.
"""
if prompts is not None:
new_metadata = new_metadata or {}
new_metadata["opik_prompts"] = prompts
return _merge_dictionary_with_data(existing_metadata, new_data=new_metadata)
def merge_inputs(
existing_inputs: Optional[Dict[str, Any]],
new_inputs: Optional[Union[Dict[str, Any], pydantic.BaseModel]],
) -> Optional[Dict[str, Any]]:
"""Merge the existing input dictionary with new data, with new values taking precedence.
If both existing_inputs and new_inputs are None or empty, return None."""
return _merge_dictionary_with_data(existing_inputs, new_data=new_inputs)
def merge_outputs(
existing_outputs: Optional[Dict[str, Any]],
new_outputs: Optional[Union[Dict[str, Any], pydantic.BaseModel]],
) -> Optional[Dict[str, Any]]:
"""Merge the existing output dictionary with new data, with new values taking precedence.
If both existing_outputs and new_outputs are None or empty, return None."""
return _merge_dictionary_with_data(existing_outputs, new_data=new_outputs)
def _merge_dictionary_with_data(
existing_dict: Optional[Dict[str, Any]],
new_data: Optional[Union[Dict[str, Any], pydantic.BaseModel]],
) -> Optional[Dict[str, Any]]:
"""Merge the dictionary with new data, with new values taking precedence.
If both existing_dict and new_data are None or empty, return None."""
if existing_dict is None and new_data is None:
return None
if isinstance(new_data, pydantic.BaseModel):
new_data = new_data.model_dump()
result = dict(existing_dict or {})
if new_data:
result = dict_utils.deepmerge(result, new_data)
return result if result else None
@@ -0,0 +1,4 @@
from .dataset import Dataset, DatasetVersion
__all__ = ["Dataset", "DatasetVersion"]
@@ -0,0 +1,99 @@
import json
from typing import List, Callable, Any, Dict, TYPE_CHECKING
import logging
if TYPE_CHECKING:
import pandas as pd
from . import dataset_item
from . import helpers
ItemConstructor = Callable[[Any], dataset_item.DatasetItem]
LOGGER = logging.getLogger(__name__)
def to_pandas(
items: List[dataset_item.DatasetItem], keys_mapping: Dict[str, str]
) -> "pd.DataFrame":
helpers.raise_if_pandas_is_unavailable()
import pandas as pd
new_item_dicts = []
for item in items:
item_content = item.get_content(include_id=True)
new_item_dict = {
keys_mapping.get(key, key): value for key, value in item_content.items()
}
new_item_dicts.append(new_item_dict)
return pd.DataFrame(new_item_dicts)
def from_jsonl_file(
file_path: str, keys_mapping: Dict[str, str], ignore_keys: List[str]
) -> List[dataset_item.DatasetItem]:
items = []
with open(file_path, "r", encoding="utf-8") as file:
for line in file:
json_object = line.strip()
if json_object: # Skip empty lines
items.append(json.loads(json_object))
json_str = json.dumps(items)
return from_json(json_str, keys_mapping, ignore_keys)
def from_pandas(
dataframe: "pd.DataFrame",
keys_mapping: Dict[str, str],
ignore_keys: List[str],
) -> List[dataset_item.DatasetItem]:
helpers.raise_if_pandas_is_unavailable()
result = []
ignore_keys = [] if ignore_keys is None else ignore_keys
for _, row in dataframe.iterrows():
item_kwargs = {
keys_mapping.get(key, key): value
for key, value in row.items()
if key not in ignore_keys
}
result.append(dataset_item.DatasetItem(**item_kwargs))
return result
def to_json(items: List[dataset_item.DatasetItem], keys_mapping: Dict[str, str]) -> str:
new_item_dicts = []
for item in items:
item_content = item.get_content(include_id=True)
new_item_dict = {
keys_mapping.get(key, key): value for key, value in item_content.items()
}
new_item_dicts.append(new_item_dict)
result: str = json.dumps(new_item_dicts, indent=2)
return result
def from_json(
value: str, keys_mapping: Dict[str, str], ignore_keys: List[str]
) -> List[dataset_item.DatasetItem]:
result = []
item_dicts: List[Dict[str, Any]] = json.loads(value)
for item_dict in item_dicts:
item_kwargs = {
keys_mapping.get(key, key): value
for key, value in item_dict.items()
if key not in ignore_keys
}
result.append(dataset_item.DatasetItem(**item_kwargs))
return result
@@ -0,0 +1,923 @@
import abc
import datetime
import logging
import functools
import sys
from typing import (
Optional,
Any,
List,
Dict,
Sequence,
Set,
TYPE_CHECKING,
Iterator,
)
from opik.api_objects import rest_helpers
from opik.rest_api import client as rest_api_client
from opik.rest_api.core.api_error import ApiError
from opik.rest_api.types import (
dataset_item_write as rest_dataset_item,
dataset_public as rest_dataset_public,
dataset_version_public,
evaluator_item_write as rest_evaluator_item,
execution_policy_write as rest_execution_policy,
)
from opik.message_processing.batching import sequence_splitter
from opik import id_helpers
import opik.exceptions as exceptions
import opik.config as config
from .. import constants
from . import dataset_item, converters, rest_operations, execution_policy
if sys.version_info >= (3, 12):
from typing import override
else:
from typing_extensions import override
if TYPE_CHECKING:
import pandas as pd
LOGGER = logging.getLogger(__name__)
class DatasetExportOperations(abc.ABC):
"""
Abstract base class providing export operations for dataset items.
This class defines the common interface for exporting dataset items,
shared by both Dataset (current state) and DatasetVersion (specific version).
"""
@abc.abstractmethod
def __internal_api__stream_items_as_dataclasses__(
self,
nb_samples: Optional[int] = None,
batch_size: Optional[int] = None,
dataset_item_ids: Optional[List[str]] = None,
filter_string: Optional[str] = None,
) -> Iterator[dataset_item.DatasetItem]:
"""
Stream dataset items as DatasetItem objects.
Args:
nb_samples: Maximum number of items to retrieve.
batch_size: Maximum number of items to fetch per batch.
dataset_item_ids: Optional list of specific item IDs to retrieve.
filter_string: Optional OQL filter string to filter dataset items.
Yields:
DatasetItem objects one at a time.
"""
raise NotImplementedError
def to_pandas(self) -> "pd.DataFrame":
"""
Convert the dataset items to a pandas DataFrame.
Requires the `pandas` library to be installed.
Returns:
A pandas DataFrame containing all items.
"""
dataset_items = list(self.__internal_api__stream_items_as_dataclasses__())
return converters.to_pandas(dataset_items, keys_mapping={})
def to_json(self) -> str:
"""
Convert the dataset items to a JSON string.
Returns:
A JSON string representation of all items.
"""
dataset_items = list(self.__internal_api__stream_items_as_dataclasses__())
return converters.to_json(dataset_items, keys_mapping={})
def get_items(
self,
nb_samples: Optional[int] = None,
filter_string: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""
Retrieve dataset items as a list of dictionaries.
Args:
nb_samples: Maximum number of items to retrieve. If not set, all items are returned.
filter_string: Optional OQL filter string to filter dataset items.
Supports filtering by tags, data fields, metadata, etc.
Supported columns include:
- `id`, `source`, `trace_id`, `span_id`: String fields
- `data`: Dictionary field (use dot notation, e.g., "data.category")
- `tags`: List field (use "contains" operator)
- `created_at`, `last_updated_at`: DateTime fields (ISO 8601 format)
- `created_by`, `last_updated_by`: String fields
Examples:
- `tags contains "failed"` - Items with 'failed' tag
- `data.category = "test"` - Items with specific data field value
- `created_at >= "2024-01-01T00:00:00Z"` - Items created after date
Returns:
A list of dictionaries representing the dataset items.
"""
dataset_items_as_dicts = [
{"id": item.id, **item.get_content()}
for item in self.__internal_api__stream_items_as_dataclasses__(
nb_samples=nb_samples, filter_string=filter_string
)
]
return dataset_items_as_dicts
@abc.abstractmethod
def get_version_info(
self,
) -> Optional[dataset_version_public.DatasetVersionPublic]:
"""
Get version information for experiment association.
Returns:
DatasetVersionPublic containing version metadata (id, version_name, etc.).
For Dataset, returns info about the current/latest version, or None if no version exists.
For DatasetVersion, returns info about this specific version.
"""
raise NotImplementedError
class DatasetVersion(DatasetExportOperations):
"""
A read-only view of a specific dataset version.
This class provides access to dataset items at a specific version point in time.
It supports reading version metadata and retrieving items, but does not allow
mutations to the dataset.
This object should not be created directly. Use :meth:`Dataset.get_dataset_version`
to obtain an instance.
"""
def __init__(
self,
dataset_name: str,
dataset_id: str,
rest_client: rest_api_client.OpikApi,
version_info: dataset_version_public.DatasetVersionPublic,
project_name: Optional[str],
client: Optional[Any] = None,
) -> None:
self._dataset_name = dataset_name
self._dataset_id = dataset_id
self._rest_client = rest_client
self._version_info = version_info
self._project_name = project_name
self.client = client
@property
def dataset_name(self) -> str:
"""The name of the dataset this version belongs to."""
return self._dataset_name
@property
def project_name(self) -> Optional[str]:
"""The name of the project this dataset belongs to."""
return self._project_name
@property
def name(self) -> str:
"""The name of the dataset this version belongs to (alias for dataset_name)."""
return self._dataset_name
@property
def dataset_id(self) -> str:
"""The unique identifier of the dataset this version belongs to."""
return self._dataset_id
@property
def id(self) -> str:
"""The unique identifier of the dataset this version belongs to (alias for dataset_id)."""
return self._dataset_id
@property
def version_id(self) -> Optional[str]:
"""The unique identifier of this specific version."""
return self._version_info.id
@property
def dataset_items_count(self) -> Optional[int]:
"""Total number of items in this version (alias for items_total)."""
return self._version_info.items_total
@property
def version_hash(self) -> Optional[str]:
"""The unique hash identifier of this version."""
return self._version_info.version_hash
@property
def version_name(self) -> Optional[str]:
"""The sequential version name (e.g., 'v1', 'v2')."""
return self._version_info.version_name
@property
def tags(self) -> Optional[List[str]]:
"""Tags associated with this version."""
return self._version_info.tags
@property
def is_latest(self) -> Optional[bool]:
"""Whether this is the latest version of the dataset."""
return self._version_info.is_latest
@property
def items_total(self) -> Optional[int]:
"""Total number of items in this version."""
return self._version_info.items_total
@property
def items_added(self) -> Optional[int]:
"""Number of items added since the previous version."""
return self._version_info.items_added
@property
def items_modified(self) -> Optional[int]:
"""Number of items modified since the previous version."""
return self._version_info.items_modified
@property
def items_deleted(self) -> Optional[int]:
"""Number of items deleted since the previous version."""
return self._version_info.items_deleted
@property
def change_description(self) -> Optional[str]:
"""Description of changes in this version."""
return self._version_info.change_description
@property
def created_at(self) -> Optional[datetime.datetime]:
"""Timestamp when this version was created."""
return self._version_info.created_at
@property
def created_by(self) -> Optional[str]:
"""User who created this version."""
return self._version_info.created_by
@override
def __internal_api__stream_items_as_dataclasses__(
self,
nb_samples: Optional[int] = None,
batch_size: Optional[int] = None,
dataset_item_ids: Optional[List[str]] = None,
filter_string: Optional[str] = None,
) -> Iterator[dataset_item.DatasetItem]:
return rest_operations.stream_dataset_items(
rest_client=self._rest_client,
dataset_name=self._dataset_name,
project_name=self._project_name,
nb_samples=nb_samples,
batch_size=batch_size,
dataset_item_ids=dataset_item_ids,
filter_string=filter_string,
dataset_version=self._version_info.version_hash,
)
@override
def get_version_info(
self,
) -> Optional[dataset_version_public.DatasetVersionPublic]:
"""
Get version information for this specific dataset version.
Returns:
DatasetVersionPublic containing this version's metadata.
"""
return self._version_info
def get_evaluators(
self,
evaluator_model: Optional[str] = None,
) -> List[Any]:
"""
Get suite-level evaluators for this dataset version.
DatasetVersion does not support suite-level evaluators, so this always
returns an empty list.
Returns:
Empty list.
"""
return []
def get_execution_policy(self) -> execution_policy.ExecutionPolicy:
"""
Get the execution policy for this dataset version.
DatasetVersion does not support suite-level execution policy, so this
returns the default execution policy.
Returns:
Default execution policy.
"""
return execution_policy.DEFAULT_EXECUTION_POLICY.copy()
class Dataset(DatasetExportOperations):
def __init__(
self,
name: str,
description: Optional[str],
project_name: Optional[str],
rest_client: rest_api_client.OpikApi,
dataset_items_count: Optional[int] = None,
client: Optional[Any] = None,
) -> None:
"""
A Dataset object. This object should not be created directly, instead use :meth:`opik.Opik.create_dataset` or :meth:`opik.Opik.get_dataset`.
"""
self._name = name
self._description = description
self._rest_client = rest_client
self._dataset_items_count = dataset_items_count
self._project_name = project_name
self.client = client
self._id_to_hash: Dict[str, str] = {}
self._hashes: Set[str] = set()
# True when the local hash cache is consistent with the backend.
# Directly-constructed Datasets (create_dataset, test-suite helpers,
# unit tests) start synced — there's nothing on the backend we haven't
# seen locally. The backend-fetch factories (`from_public`,
# `rest_operations.get_datasets`) flip this to False so dedup does a
# one-shot sync on the first `insert()` instead of paying an N+1
# sync at list time.
self._hashes_synced: bool = True
@classmethod
def from_public(
cls,
dataset_fern: rest_dataset_public.DatasetPublic,
project_name: str,
rest_client: rest_api_client.OpikApi,
client: Optional[Any] = None,
) -> "Dataset":
"""Build a Dataset from a backend response, resolving the actual project.
The backend may find the dataset via workspace-wide fallback even when
the caller's project_name doesn't match the dataset's actual project.
This method uses project_id from the response to resolve the real
project name, so downstream calls target the correct project.
"""
actual_project_name: Optional[str] = None
if dataset_fern.project_id is not None:
actual_project_name = rest_client.projects.get_project_by_id(
dataset_fern.project_id
).name
dataset_ = cls(
name=dataset_fern.name,
description=dataset_fern.description,
project_name=actual_project_name or project_name,
rest_client=rest_client,
dataset_items_count=dataset_fern.dataset_items_count,
client=client,
)
# Backend may already hold items we haven't seen; lazy-sync on first
# insert so content-hash dedup still works without paying a sync now.
dataset_.__internal_api__hashes_synced__ = False
return dataset_
@functools.cached_property
def id(self) -> str:
"""The id of the dataset"""
return self._rest_client.datasets.get_dataset_by_identifier(
dataset_name=self._name, project_name=self._project_name
).id
@property
def name(self) -> str:
"""The name of the dataset."""
return self._name
@property
def project_name(self) -> Optional[str]:
"""The name of the project this dataset belongs to."""
return self._project_name
@property
def description(self) -> Optional[str]:
"""The description of the dataset."""
return self._description
@property
def dataset_items_count(self) -> Optional[int]:
"""
The total number of items in the dataset.
If the count is not cached locally, it will be fetched from the backend.
"""
if self._dataset_items_count is None:
dataset_info = self._rest_client.datasets.get_dataset_by_id(id=self.id)
self._dataset_items_count = dataset_info.dataset_items_count
return self._dataset_items_count
def get_current_version_name(self) -> Optional[str]:
"""
Get the current version name of the dataset.
The version name is fetched from the backend and reflects the latest
committed version after any mutation operations (insert, update, delete).
Returns:
The current version name (e.g., 'v1', 'v2'), or None if no version exists.
"""
version_info = self.get_version_info()
return version_info.version_name if version_info else None
@override
def get_version_info(
self,
) -> Optional[dataset_version_public.DatasetVersionPublic]:
"""
Get version information for the current (latest) dataset version.
Returns:
DatasetVersionPublic containing the current version's metadata,
or None if no version exists yet.
"""
versions_response = None
try:
versions_response = self._rest_client.datasets.list_dataset_versions(
id=self.id,
page=1,
size=1,
)
except ApiError as e:
if e.status_code == 403:
LOGGER.debug(
"Versioning is not enabled for datasets get version info returning None"
)
else:
raise
if not versions_response or not versions_response.content:
return None
return versions_response.content[0]
def get_evaluators(
self,
evaluator_model: Optional[str] = None,
) -> List[Any]:
"""
Get suite-level evaluators from the current dataset version.
Converts EvaluatorItemPublic objects from the BE into LLMJudge instances.
Args:
evaluator_model: Optional model name to use for LLMJudge evaluators.
Returns:
List of LLMJudge instances extracted from the version.
"""
from opik.evaluation.suite_evaluators import llm_judge
from opik.evaluation.suite_evaluators.llm_judge import (
config as llm_judge_config,
)
version_info = self.get_version_info()
if version_info is None or not version_info.evaluators:
return []
evaluators: List[Any] = []
for evaluator_item in version_info.evaluators:
try:
if evaluator_item.type == "llm_judge":
cfg = llm_judge_config.LLMJudgeConfig(**evaluator_item.config)
evaluator = llm_judge.LLMJudge.from_config(
cfg, init_kwargs={"model": evaluator_model}
)
evaluators.append(evaluator)
else:
LOGGER.warning(
"Unsupported evaluator type in version: %s. Only 'llm_judge' is supported.",
evaluator_item.type,
)
except Exception:
LOGGER.error(
"Failed to instantiate evaluator from version config: %s",
evaluator_item.config,
exc_info=True,
)
raise
return evaluators
def get_execution_policy(
self,
) -> execution_policy.ExecutionPolicy:
"""
Get suite-level execution policy from the current dataset version.
Returns:
ExecutionPolicy dict with runs_per_item and pass_threshold.
"""
version_info = self.get_version_info()
if version_info is not None and version_info.execution_policy is not None:
ep = version_info.execution_policy
return {
"runs_per_item": ep.runs_per_item
if ep.runs_per_item is not None
else 1,
"pass_threshold": ep.pass_threshold
if ep.pass_threshold is not None
else 1,
}
return execution_policy.DEFAULT_EXECUTION_POLICY.copy()
def get_tags(self) -> List[str]:
"""
Get the tags for this dataset.
Returns:
List of tag strings.
"""
dataset_fern = self._rest_client.datasets.get_dataset_by_identifier(
dataset_name=self._name, project_name=self._project_name
)
return dataset_fern.tags or []
def _convert_to_rest_item(
self, item: dataset_item.DatasetItem
) -> rest_dataset_item.DatasetItemWrite:
"""Convert a DatasetItem to REST API format.
Args:
item: The DatasetItem to convert.
Returns:
DatasetItemWrite object ready for REST API.
"""
evaluators = None
if item.evaluators:
evaluators = [
rest_evaluator_item.EvaluatorItemWrite(
name=e.name,
type=e.type, # type: ignore
config=e.config,
)
for e in item.evaluators
]
execution_policy = None
if item.execution_policy:
execution_policy = rest_execution_policy.ExecutionPolicyWrite(
runs_per_item=item.execution_policy.runs_per_item,
pass_threshold=item.execution_policy.pass_threshold,
)
return rest_dataset_item.DatasetItemWrite(
id=item.id, # type: ignore
trace_id=item.trace_id, # type: ignore
span_id=item.span_id, # type: ignore
source=item.source, # type: ignore
data=item.get_content(),
description=item.description,
evaluators=evaluators,
execution_policy=execution_policy,
)
def _insert_batch_with_retry(
self,
batch: List[rest_dataset_item.DatasetItemWrite],
batch_group_id: str,
) -> None:
"""Insert a batch of dataset items with automatic retry on rate limit errors.
Args:
batch: List of dataset items to insert.
batch_group_id: UUIDv7 identifier that groups all batches from a single
user operation together. All batches sent as part of one insert/update
call share the same batch_group_id.
"""
rest_helpers.ensure_rest_api_call_respecting_rate_limit(
lambda: self._rest_client.datasets.create_or_update_dataset_items(
dataset_name=self._name,
items=batch,
batch_group_id=batch_group_id,
project_name=self._project_name,
)
)
LOGGER.debug("Successfully sent dataset items batch of size %d", len(batch))
def __internal_api__insert_items_as_dataclasses__(
self, items: List[dataset_item.DatasetItem]
) -> None:
# Lazy-sync against the backend the first time we insert into a
# dataset that was fetched from the backend (list or get-by-name
# factory), so content-hash dedup still works without paying an
# N+1 sync at list time.
if not self._hashes_synced:
self.__internal_api__sync_hashes__()
# Remove duplicates if they already exist
deduplicated_items: List[dataset_item.DatasetItem] = []
for item in items:
item_hash = item.content_hash()
if item_hash in self._hashes:
LOGGER.debug(
"Duplicate item found with hash: %s - ignored the event",
item_hash,
)
continue
deduplicated_items.append(item)
self._hashes.add(item_hash)
self._id_to_hash[item.id] = item_hash
rest_items = [self._convert_to_rest_item(item) for item in deduplicated_items]
batches = sequence_splitter.split_into_batches(
rest_items,
max_payload_size_MB=config.MAX_BATCH_SIZE_MB,
max_length=constants.DATASET_ITEMS_MAX_BATCH_SIZE,
)
batch_group_id = id_helpers.generate_id()
for batch in batches:
LOGGER.debug("Sending dataset items batch of size %d", len(batch))
self._insert_batch_with_retry(batch, batch_group_id=batch_group_id)
# Invalidate the cached count so it will be fetched from backend on next access
self._dataset_items_count = None
def insert(self, items: Sequence[Dict[str, Any]]) -> None:
"""
Insert new items into the dataset. A new dataset version will be created.
Args:
items: List of dicts (which will be converted to dataset items)
to add to the dataset.
"""
dataset_items: List[dataset_item.DatasetItem] = [ # type: ignore
(dataset_item.DatasetItem(**item) if isinstance(item, dict) else item)
for item in items
]
self.__internal_api__insert_items_as_dataclasses__(dataset_items)
@property
def __internal_api__hashes_synced__(self) -> bool:
"""Whether the local hash cache is in sync with the backend.
`__init__` defaults this to True (a freshly constructed Dataset
has no backend state to sync). Factory paths that construct a
Dataset from an existing backend state (`from_public`,
`rest_operations.get_datasets`) flip it to False so the first
:meth:`insert` triggers a one-shot sync instead of paying an
N+1 sync at list time.
"""
return self._hashes_synced
@__internal_api__hashes_synced__.setter
def __internal_api__hashes_synced__(self, value: bool) -> None:
self._hashes_synced = value
def __internal_api__sync_hashes__(self) -> None:
"""Updates all the hashes in the dataset"""
LOGGER.debug("Start hash sync in dataset")
self._id_to_hash = {}
self._hashes = set()
for item in self.__internal_api__stream_items_as_dataclasses__():
item_hash = item.content_hash()
self._id_to_hash[item.id] = item_hash # type: ignore
self._hashes.add(item_hash)
self._hashes_synced = True
LOGGER.debug("Finish hash sync in dataset")
def update(self, items: List[Dict[str, Any]]) -> None:
"""
Update existing items in the dataset.
Args:
items: List of DatasetItem objects to update in the dataset. You need to provide the full item object as it will override what has been supplied previously.
Raises:
DatasetItemUpdateOperationRequiresItemId: If any item in the list is missing an id.
"""
for item in items:
if "id" not in item:
raise exceptions.DatasetItemUpdateOperationRequiresItemId(
"Missing id for dataset item to update: %s", item
)
self.insert(items)
def _delete_batch_with_retry(
self,
batch: List[str],
batch_group_id: str,
) -> None:
"""Delete a batch of dataset items with automatic retry on rate limit errors.
Args:
batch: List of item IDs to delete.
batch_group_id: UUIDv7 identifier that groups all batches from a single
user operation together. All batches sent as part of one delete
call share the same batch_group_id.
"""
rest_helpers.ensure_rest_api_call_respecting_rate_limit(
lambda: self._rest_client.datasets.delete_dataset_items(
item_ids=batch, batch_group_id=batch_group_id
)
)
LOGGER.debug("Successfully deleted dataset items batch of size %d", len(batch))
def delete(self, items_ids: List[str]) -> None:
"""
Delete items from the dataset. A new dataset version will be created.
Args:
items_ids: List of item ids to delete.
"""
batches = sequence_splitter.split_into_batches(
items_ids, max_length=constants.DATASET_ITEMS_MAX_BATCH_SIZE
)
batch_group_id = id_helpers.generate_id()
for batch in batches:
LOGGER.debug("Deleting dataset items batch: %s", batch)
self._delete_batch_with_retry(batch, batch_group_id=batch_group_id)
for item_id in batch:
if item_id in self._id_to_hash:
hash = self._id_to_hash[item_id]
self._hashes.discard(hash)
del self._id_to_hash[item_id]
# Invalidate the cached count so it will be fetched from backend on next access
self._dataset_items_count = None
def clear(self) -> None:
"""
Delete all items from the given dataset. A new dataset version will be created.
"""
item_ids = [
item.id
for item in self.__internal_api__stream_items_as_dataclasses__()
if item.id is not None
]
self.delete(item_ids)
@override
def __internal_api__stream_items_as_dataclasses__(
self,
nb_samples: Optional[int] = None,
batch_size: Optional[int] = None,
dataset_item_ids: Optional[List[str]] = None,
filter_string: Optional[str] = None,
) -> Iterator[dataset_item.DatasetItem]:
"""
Stream dataset items as a generator instead of loading all at once.
This method yields dataset items one at a time, enabling evaluation to start
processing items before the entire dataset is downloaded. This is particularly
useful for large datasets with heavy payloads (images, videos, audio).
Args:
nb_samples: Maximum number of items to retrieve. If None, all items are streamed.
batch_size: Maximum number of items to fetch per batch from the backend.
If None, uses the default value from constants.DATASET_STREAM_BATCH_SIZE.
dataset_item_ids: Optional list of specific item IDs to retrieve. If provided,
only items with matching IDs will be yielded.
filter_string: Optional OQL filter string to filter dataset items.
Yields:
DatasetItem objects one at a time
"""
return rest_operations.stream_dataset_items(
rest_client=self._rest_client,
dataset_name=self._name,
project_name=self._project_name,
nb_samples=nb_samples,
batch_size=batch_size,
dataset_item_ids=dataset_item_ids,
filter_string=filter_string,
dataset_version=None,
)
def insert_from_json(
self,
json_array: str,
keys_mapping: Optional[Dict[str, str]] = None,
ignore_keys: Optional[List[str]] = None,
) -> None:
"""
Args:
json_array: json string of format: "[{...}, {...}, {...}]" where every dictionary
is to be transformed into dataset item
keys_mapping: dictionary that maps json keys to item fields names
Example: {'Expected output': 'expected_output'}
ignore_keys: if your json dicts contain keys that are not needed for DatasetItem
construction - pass them as ignore_keys argument
"""
keys_mapping = {} if keys_mapping is None else keys_mapping
ignore_keys = [] if ignore_keys is None else ignore_keys
new_items = converters.from_json(
json_array, keys_mapping=keys_mapping, ignore_keys=ignore_keys
)
self.insert(new_items)
def read_jsonl_from_file(
self,
file_path: str,
keys_mapping: Optional[Dict[str, str]] = None,
ignore_keys: Optional[List[str]] = None,
) -> None:
"""
Read JSONL from a file and insert it into the dataset.
Args:
file_path: Path to the JSONL file
keys_mapping: dictionary that maps json keys to item fields names
Example: {'Expected output': 'expected_output'}
ignore_keys: if your json dicts contain keys that are not needed for DatasetItem
construction - pass them as ignore_keys argument
"""
keys_mapping = {} if keys_mapping is None else keys_mapping
ignore_keys = [] if ignore_keys is None else ignore_keys
new_items = converters.from_jsonl_file(file_path, keys_mapping, ignore_keys)
self.insert(new_items)
def insert_from_pandas(
self,
dataframe: "pd.DataFrame",
keys_mapping: Optional[Dict[str, str]] = None,
ignore_keys: Optional[List[str]] = None,
) -> None:
"""
Requires: `pandas` library to be installed.
Args:
dataframe: pandas dataframe
keys_mapping: Dictionary that maps dataframe column names to dataset item field names.
Example: {'Expected output': 'expected_output'}
ignore_keys: if your dataframe contains columns that are not needed for DatasetItem
construction - pass them as ignore_keys argument
"""
keys_mapping = {} if keys_mapping is None else keys_mapping
ignore_keys = [] if ignore_keys is None else ignore_keys
new_items = converters.from_pandas(dataframe, keys_mapping, ignore_keys)
self.insert(new_items)
def get_version_view(self, version_name: str) -> DatasetVersion:
"""
Get a read-only view of a specific dataset version.
The returned DatasetVersion object allows reading version metadata and
retrieving items via :meth:`DatasetVersion.get_items`, but does not support
mutations.
Args:
version_name: The version name (e.g., 'v1', 'v2').
Returns:
A read-only DatasetVersion object for accessing the specified version.
Raises:
opik.exceptions.DatasetVersionNotFound: If the specified version does not exist.
Example:
>>> dataset = client.get_dataset("my_dataset")
>>> version = dataset.get_version_view("v1")
>>> items = version.get_items()
"""
version_info = rest_operations.find_version_by_name(
rest_client=self._rest_client,
dataset_id=self.id,
version_name=version_name,
)
if version_info is None:
raise exceptions.DatasetVersionNotFound(
f"Dataset version '{version_name}' not found in dataset '{self._name}'"
)
return DatasetVersion(
dataset_name=self._name,
dataset_id=self.id,
rest_client=self._rest_client,
version_info=version_info,
project_name=self._project_name,
client=self.client,
)
@@ -0,0 +1,106 @@
from typing import Optional, Dict, Any, List
import pydantic
import json
import hashlib
from .. import constants, helpers
class EvaluatorItem(pydantic.BaseModel):
"""
An evaluator configuration for a dataset item.
"""
model_config = pydantic.ConfigDict(extra="allow", strict=False)
name: str
"""The name of the evaluator."""
type: str
"""The type of evaluator (e.g., 'llm_judge', 'code_metric')."""
config: Dict[str, Any]
"""The evaluator configuration."""
class ExecutionPolicyItem(pydantic.BaseModel):
"""
Execution policy for a dataset item.
"""
model_config = pydantic.ConfigDict(extra="allow", strict=False)
runs_per_item: Optional[int] = None
"""Number of times to run the task for this item."""
pass_threshold: Optional[int] = None
"""Minimum number of runs that must pass for the item to pass."""
class DatasetItem(pydantic.BaseModel):
"""
A DatasetItem object representing an item in a dataset.
The format is flexible.
"""
model_config = pydantic.ConfigDict(extra="allow", strict=False)
id: pydantic.SkipValidation[str] = pydantic.Field(
default_factory=helpers.generate_id
)
"""The unique identifier for this dataset item."""
trace_id: Optional[str] = None
"""The ID of the trace associated with this dataset item."""
span_id: Optional[str] = None
"""The ID of the span associated with this dataset item."""
source: str = constants.DATASET_SOURCE_SDK
"""The source of the dataset item. Defaults to DATASET_SOURCE_SDK."""
description: Optional[str] = None
"""Optional description of the dataset item."""
evaluators: Optional[List[EvaluatorItem]] = None
"""List of evaluators configured for this dataset item."""
execution_policy: Optional[ExecutionPolicyItem] = None
"""Execution policy for this dataset item."""
def get_content(
self,
include_id: bool = False,
) -> Dict[str, Any]:
"""
Get the data content of the dataset item (extra fields).
Note: evaluators and execution_policy are not included in data content
Args:
include_id: Whether to include the item ID in the content.
Returns:
Dictionary containing the item's extra fields.
"""
content = {**self.model_extra}
if include_id:
content["id"] = self.id
return content
def content_hash(self) -> str:
content = self.get_content()
if self.description is not None:
content["description"] = self.description
if self.evaluators is not None:
content["evaluators"] = [e.model_dump() for e in self.evaluators]
if self.execution_policy is not None:
content["execution_policy"] = self.execution_policy.model_dump()
json_string = json.dumps(content, sort_keys=True)
hash_object = hashlib.sha256(json_string.encode())
return hash_object.hexdigest()
@@ -0,0 +1,22 @@
"""Execution policy type and default for dataset evaluation."""
from typing import TypedDict
class ExecutionPolicy(TypedDict, total=False):
"""
Execution policy for test suite items.
Attributes:
runs_per_item: Number of times to run evaluation per item.
pass_threshold: Minimum number of passing runs required for item to pass.
"""
runs_per_item: int
pass_threshold: int
DEFAULT_EXECUTION_POLICY: ExecutionPolicy = {
"runs_per_item": 1,
"pass_threshold": 1,
}
@@ -0,0 +1,9 @@
import importlib.util
def raise_if_pandas_is_unavailable() -> None:
if importlib.util.find_spec("pandas") is None:
raise ImportError(
"The Python library Pandas is required for this method. "
"You can install it with `pip install pandas`."
)
@@ -0,0 +1,495 @@
from __future__ import annotations
import json
import logging
from typing import Any, Dict, Iterator, List, Optional, Set, TYPE_CHECKING
from opik.rest_api import OpikApi
from opik.rest_api.types import (
dataset_item as rest_dataset_item_read,
dataset_version_public,
)
import opik.exceptions as exceptions
from opik.message_processing import streamer
from opik.rest_client_configurator import retry_decorator
from opik.api_objects import opik_query_language, rest_stream_parser
from . import dataset, dataset_item, execution_policy
from .. import experiment, constants, rest_helpers
from ..experiment import experiments_client
from ...rest_api.core.api_error import ApiError
if TYPE_CHECKING:
from opik.evaluation.suite_evaluators import llm_judge
from .test_suite.test_suite import TestSuite
LOGGER = logging.getLogger(__name__)
def stream_dataset_items(
rest_client: OpikApi,
dataset_name: str,
project_name: Optional[str],
nb_samples: Optional[int] = None,
batch_size: Optional[int] = None,
dataset_item_ids: Optional[List[str]] = None,
filter_string: Optional[str] = None,
dataset_version: Optional[str] = None,
) -> Iterator[dataset_item.DatasetItem]:
"""
Stream dataset items from the backend as a generator.
Args:
rest_client: The REST API client.
dataset_name: Name of the dataset to stream items from.
project_name: Name of the project to stream items from.
nb_samples: Maximum number of items to retrieve. If None, all items are streamed.
batch_size: Maximum number of items to fetch per batch from the backend.
dataset_item_ids: Optional list of specific item IDs to retrieve.
filter_string: Optional OQL filter string to filter dataset items.
dataset_version: Optional dataset version hash to filter items by a specific version.
Yields:
DatasetItem objects one at a time.
"""
if batch_size is None:
batch_size = constants.DATASET_STREAM_BATCH_SIZE
last_retrieved_id: Optional[str] = None
should_retrieve_more_items = True
items_yielded = 0
dataset_items_ids_left: Optional[Set[str]] = (
set(dataset_item_ids) if dataset_item_ids else None
)
_conflicting_keys_warned = False
filters: Optional[str] = None
if filter_string:
oql = opik_query_language.OpikQueryLanguage.for_dataset_items(filter_string)
filter_expressions = oql.get_filter_expressions()
if filter_expressions:
filters = json.dumps(filter_expressions)
while should_retrieve_more_items:
@retry_decorator.opik_rest_retry
def _fetch_batch() -> List[rest_dataset_item_read.DatasetItem]:
return rest_stream_parser.read_and_parse_stream(
stream=rest_client.datasets.stream_dataset_items(
dataset_name=dataset_name,
project_name=project_name,
last_retrieved_id=last_retrieved_id,
steam_limit=batch_size,
filters=filters,
dataset_version=dataset_version,
),
item_class=rest_dataset_item_read.DatasetItem,
nb_samples=nb_samples,
)
dataset_items = _fetch_batch()
if len(dataset_items) == 0:
should_retrieve_more_items = False
break
for item in dataset_items:
item_id = item.id
last_retrieved_id = item_id
if dataset_items_ids_left is not None:
if item_id not in dataset_items_ids_left:
continue
else:
dataset_items_ids_left.remove(item_id)
# Convert evaluators from REST format to DatasetItem format
evaluators = None
if item.evaluators:
evaluators = [
dataset_item.EvaluatorItem(
name=e.name,
type=e.type,
config=e.config,
)
for e in item.evaluators
]
# Convert execution_policy from REST format to DatasetItem format
execution_policy = None
if item.execution_policy:
execution_policy = dataset_item.ExecutionPolicyItem(
runs_per_item=item.execution_policy.runs_per_item,
pass_threshold=item.execution_policy.pass_threshold,
)
# Strip DatasetItem field names from user data before unpacking to avoid
# "multiple values for keyword argument" errors. This happens when user data
# contains a key that matches a DatasetItem field (e.g. 'id' in HotpotQA).
conflicting = (
item.data.keys() & dataset_item.DatasetItem.model_fields.keys()
)
if conflicting and not _conflicting_keys_warned:
_conflicting_keys_warned = True
LOGGER.warning(
"Dataset item data contains keys that shadow DatasetItem fields and will be ignored: %s. "
"Rename these keys in your dataset to preserve them.",
sorted(conflicting),
)
extra_data = {
k: v
for k, v in item.data.items()
if k not in dataset_item.DatasetItem.model_fields
}
reconstructed_item = dataset_item.DatasetItem(
id=item.id,
trace_id=item.trace_id,
span_id=item.span_id,
source=item.source,
description=item.description,
evaluators=evaluators,
execution_policy=execution_policy,
**extra_data,
)
yield reconstructed_item
items_yielded += 1
if nb_samples is not None and items_yielded >= nb_samples:
should_retrieve_more_items = False
break
if dataset_items_ids_left is not None and len(dataset_items_ids_left) == 0:
should_retrieve_more_items = False
break
if dataset_items_ids_left and len(dataset_items_ids_left) > 0:
LOGGER.warning(
"The following dataset items were not found in the dataset: %s",
dataset_items_ids_left,
)
def find_version_by_name(
rest_client: OpikApi,
dataset_id: str,
version_name: str,
) -> Optional[dataset_version_public.DatasetVersionPublic]:
"""
Find a dataset version by version name.
Args:
rest_client: The REST API client.
dataset_id: The dataset ID to search versions in.
version_name: Version name to search for (e.g., 'v1', 'v2').
Returns:
The DatasetVersionPublic if found, None otherwise.
"""
try:
return rest_client.datasets.retrieve_dataset_version(
id=dataset_id, version_name=version_name
)
except ApiError as e:
if e.status_code == 404:
return None
raise
def get_datasets(
project_name: Optional[str],
rest_client: OpikApi,
max_results: int = 1000,
sync_items: bool = False,
) -> List[dataset.Dataset]:
page_size = 100
datasets: List[dataset.Dataset] = []
page = 1
project_id = rest_helpers.resolve_project_id_by_name_optional(
rest_client, project_name=project_name
)
while len(datasets) < max_results:
page_datasets = rest_client.datasets.find_datasets(
page=page,
size=page_size,
project_id=project_id,
)
if len(page_datasets.content) == 0:
break
for dataset_fern in page_datasets.content[: (max_results - len(datasets))]:
dataset_ = dataset.Dataset(
name=dataset_fern.name,
description=dataset_fern.description,
project_name=project_name,
rest_client=rest_client,
dataset_items_count=dataset_fern.dataset_items_count,
)
if sync_items:
dataset_.__internal_api__sync_hashes__()
else:
# Backend holds items we haven't seen locally; defer the sync
# until the first `insert()` so dedup still works without
# paying an N+1 sync right now.
dataset_.__internal_api__hashes_synced__ = False
datasets.append(dataset_)
page += 1
return datasets
def get_test_suites(
project_name: Optional[str],
rest_client: OpikApi,
max_results: int = 1000,
client: Optional[Any] = None,
) -> List[TestSuite]:
from .test_suite import test_suite as test_suite_module
page_size = 100
suites: List[test_suite_module.TestSuite] = []
page = 1
project_id = rest_helpers.resolve_project_id_by_name_optional(
rest_client, project_name=project_name
)
while len(suites) < max_results:
page_datasets = rest_client.datasets.find_datasets(
page=page,
size=page_size,
project_id=project_id,
)
if len(page_datasets.content) == 0:
break
for dataset_fern in page_datasets.content:
if len(suites) >= max_results:
break
if dataset_fern.type != "evaluation_suite":
continue
suite_dataset = dataset.Dataset(
name=dataset_fern.name,
description=dataset_fern.description,
project_name=project_name,
rest_client=rest_client,
dataset_items_count=dataset_fern.dataset_items_count,
client=client,
)
suites.append(
test_suite_module.TestSuite(
name=dataset_fern.name,
dataset_=suite_dataset,
client=client,
)
)
page += 1
return suites
def get_dataset_id(
rest_client: OpikApi, dataset_name: str, project_name: Optional[str]
) -> str:
try:
dataset_id = rest_client.datasets.get_dataset_by_identifier(
dataset_name=dataset_name, project_name=project_name
).id
except ApiError as e:
if e.status_code == 404:
raise exceptions.DatasetNotFound(
f"Dataset with the name {dataset_name} not found."
) from e
raise
return dataset_id
def get_dataset_experiments(
rest_client: OpikApi,
dataset_id: str,
max_results: int,
streamer: streamer.Streamer,
experiments_client: experiments_client.ExperimentsClient,
) -> List[experiment.Experiment]:
page_size = 100
experiments: List[experiment.Experiment] = []
page = 1
while len(experiments) < max_results:
page_experiments = rest_client.experiments.find_experiments(
page=page,
size=page_size,
dataset_id=dataset_id,
)
if len(page_experiments.content) == 0:
break
for experiment_ in page_experiments.content[: max_results - len(experiments)]:
experiments.append(
experiment.Experiment(
id=experiment_.id,
name=experiment_.name,
dataset_name=experiment_.dataset_name,
rest_client=rest_client,
streamer=streamer,
experiments_client=experiments_client,
tags=experiment_.tags,
)
)
page += 1
return experiments
def create_test_suite_dataset(
rest_client: OpikApi,
dataset_name: str,
project_name: Optional[str],
description: Optional[str],
evaluators: Optional[List[llm_judge.LLMJudge]],
exec_policy: Optional[execution_policy.ExecutionPolicy],
tags: Optional[List[str]] = None,
) -> str:
"""
Create a dataset of type 'test_suite' and its initial version
with evaluators and execution_policy persisted to the backend.
Args:
rest_client: The REST API client.
dataset_name: The name of the dataset/suite.
project_name: The name of the project.
description: Optional description.
evaluators: LLMJudge evaluators.
exec_policy: Execution policy dict.
tags: Optional list of tags for the suite.
Returns:
The dataset ID.
"""
rest_client.datasets.create_dataset(
name=dataset_name,
description=description,
project_name=project_name,
# TODO: OPIK-5795 - migrate DB value from 'evaluation_suite' to 'test_suite'
type="evaluation_suite",
tags=tags,
)
dataset_fern = rest_client.datasets.get_dataset_by_identifier(
dataset_name=dataset_name,
project_name=project_name,
)
# Skip initial version when there is no metadata to persist.
# This avoids an empty v1 that the TS SDK doesn't create (OPIK-5815).
if not evaluators and not exec_policy:
return dataset_fern.id
resolved_policy = exec_policy or execution_policy.DEFAULT_EXECUTION_POLICY.copy()
request: Dict[str, Any] = {
"change_description": "Suite created via SDK",
}
if evaluators:
request["evaluators"] = [
{
"name": e.name,
"type": "llm_judge",
"config": e.to_config().model_dump(by_alias=True),
}
for e in evaluators
]
request["execution_policy"] = {
"runs_per_item": resolved_policy.get("runs_per_item", 1),
"pass_threshold": resolved_policy.get("pass_threshold", 1),
}
rest_client.datasets.apply_dataset_item_changes(
id=dataset_fern.id, request=request, override=True
)
return dataset_fern.id
def create_initial_test_suite_version(
rest_client: OpikApi,
dataset_id: str,
evaluators: List[llm_judge.LLMJudge],
exec_policy: execution_policy.ExecutionPolicy,
) -> None:
"""
Create the first version for a test suite that has no versions yet.
Uses override=True since there is no base version to build on.
"""
request: Dict[str, Any] = {
"change_description": "Suite created via SDK",
}
if evaluators:
request["evaluators"] = [
{
"name": e.name,
"type": "llm_judge",
"config": e.to_config().model_dump(by_alias=True),
}
for e in evaluators
]
request["execution_policy"] = {
"runs_per_item": exec_policy.get("runs_per_item", 1),
"pass_threshold": exec_policy.get("pass_threshold", 1),
}
rest_client.datasets.apply_dataset_item_changes(
id=dataset_id, request=request, override=True
)
def update_test_suite_dataset(
rest_client: OpikApi,
dataset_id: str,
base_version_id: str,
evaluators: List[llm_judge.LLMJudge],
exec_policy: execution_policy.ExecutionPolicy,
change_description: Optional[str] = None,
) -> None:
"""
Update suite-level evaluators and execution_policy by creating a new
dataset version based on the current latest version.
Args:
rest_client: The REST API client.
dataset_id: The dataset ID.
base_version_id: The current latest version UUID to base the update on.
evaluators: Suite-level LLMJudge evaluators.
exec_policy: Execution policy dict.
change_description: Optional description of the change for the new version.
"""
request: Dict[str, Any] = {
"base_version": base_version_id,
"evaluators": [
{
"name": e.name,
"type": "llm_judge",
"config": e.to_config().model_dump(by_alias=True),
}
for e in evaluators
],
"execution_policy": {
"runs_per_item": exec_policy.get("runs_per_item", 1),
"pass_threshold": exec_policy.get("pass_threshold", 1),
},
}
if change_description:
request["change_description"] = change_description
rest_client.datasets.apply_dataset_item_changes(
id=dataset_id, request=request, override=False
)
@@ -0,0 +1,8 @@
from .test_suite import TestSuite, TestSuiteVersion
from .types import TestSuiteItem
__all__ = [
"TestSuite",
"TestSuiteVersion",
"TestSuiteItem",
]
@@ -0,0 +1,226 @@
"""Converters between raw dataset/REST formats and test suite formats.
The two key adapter functions provide bidirectional conversion:
* :func:`dataset_item_to_suite_item_dict` — DatasetItem → TestSuiteItem (exports)
* :func:`suite_item_dict_to_dataset_item` — TestSuiteItem → DatasetItem (imports)
These adapters bridge a **structural** gap between the flat DatasetItem model
(extra fields stored via pydantic ``model_extra``) and the nested TestSuiteItem
format (``data``, ``assertions``, ``execution_policy``). Because of this
structural difference the generic ``dataset/converters`` serialisation helpers
cannot be reused directly for test-suite I/O.
"""
from __future__ import annotations
import json
from typing import Any, Dict, List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
import pandas as pd
from opik.evaluation.suite_evaluators.llm_judge import LLMJudge
from opik import id_helpers
from opik.api_objects.dataset import dataset_item, validators, helpers
from opik.api_objects.dataset.test_suite import types as suite_types
from opik.rest_api.types import (
evaluator_item_public as rest_evaluator_item_public,
execution_policy_public as rest_execution_policy_public,
)
from .. import execution_policy
def evaluators_to_assertions(evaluators: List[LLMJudge]) -> List[str]:
"""Extract assertion strings from a list of LLMJudge instances."""
assertions: List[str] = []
for evaluator in evaluators:
assertions.extend(evaluator.assertions)
return assertions
def version_evaluators_to_assertions(
evaluators: Optional[List[rest_evaluator_item_public.EvaluatorItemPublic]],
) -> List[str]:
"""Extract assertion strings from REST evaluator items on a dataset version."""
from opik.evaluation.suite_evaluators import llm_judge
from opik.evaluation.suite_evaluators.llm_judge import config as llm_judge_config
assertions: List[str] = []
if evaluators:
for evaluator in evaluators:
if evaluator.type == "llm_judge":
cfg = llm_judge_config.LLMJudgeConfig(**evaluator.config)
judge = llm_judge.LLMJudge.from_config(cfg)
assertions.extend(judge.assertions)
return assertions
def version_policy_to_execution_policy(
policy: Optional[rest_execution_policy_public.ExecutionPolicyPublic],
) -> execution_policy.ExecutionPolicy:
"""Convert a REST execution policy object to an ExecutionPolicy dict."""
if policy:
return execution_policy.ExecutionPolicy(
runs_per_item=policy.runs_per_item or 1,
pass_threshold=policy.pass_threshold or 1,
)
return execution_policy.DEFAULT_EXECUTION_POLICY.copy()
def dataset_item_to_suite_item_dict(
item: dataset_item.DatasetItem,
) -> suite_types.TestSuiteItem:
"""Convert a DatasetItem into a TestSuiteItem dict with decoded assertions."""
result = suite_types.TestSuiteItem(
id=item.id,
data=item.get_content(),
assertions=version_evaluators_to_assertions(item.evaluators),
)
if item.description is not None:
result["description"] = item.description
if item.execution_policy is not None:
result["execution_policy"] = {
"runs_per_item": item.execution_policy.runs_per_item or 1,
"pass_threshold": item.execution_policy.pass_threshold or 1,
}
return result
def suite_item_dict_to_dataset_item(
item: suite_types.TestSuiteItem,
) -> dataset_item.DatasetItem:
"""Convert a TestSuiteItem dict into a DatasetItem with evaluators.
This is the inverse of :func:`dataset_item_to_suite_item_dict`.
"""
evaluators = validators.resolve_evaluators(
item.get("assertions"), None, "item-level assertions"
)
evaluator_items = None
if evaluators:
evaluator_items = [
dataset_item.EvaluatorItem(
name=e.name,
type="llm_judge",
config=e.to_config().model_dump(by_alias=True),
)
for e in evaluators
]
ep = item.get("execution_policy")
execution_policy_item = None
if ep:
execution_policy_item = dataset_item.ExecutionPolicyItem(
runs_per_item=ep.get("runs_per_item"),
pass_threshold=ep.get("pass_threshold"),
)
return dataset_item.DatasetItem(
id=item.get("id", id_helpers.generate_id()),
description=item.get("description"),
evaluators=evaluator_items,
execution_policy=execution_policy_item,
**item["data"],
)
# ---------------------------------------------------------------------------
# Export
# ---------------------------------------------------------------------------
def to_json(items: List[suite_types.TestSuiteItem]) -> str:
"""Serialise a list of TestSuiteItem dicts to a JSON string."""
return json.dumps(items, indent=2)
def to_pandas(items: List[suite_types.TestSuiteItem]) -> "pd.DataFrame":
"""Convert a list of TestSuiteItem dicts to a pandas DataFrame."""
helpers.raise_if_pandas_is_unavailable()
import pandas as pd
return pd.DataFrame(items)
# ---------------------------------------------------------------------------
# Import
# ---------------------------------------------------------------------------
def _apply_key_mapping(
item_dict: Dict[str, Any],
keys_mapping: Dict[str, str],
ignore_keys: List[str],
) -> Dict[str, Any]:
return {
keys_mapping.get(key, key): value
for key, value in item_dict.items()
if key not in ignore_keys
}
def _from_dicts(
item_dicts: List[Dict[str, Any]],
keys_mapping: Dict[str, str],
ignore_keys: List[str],
) -> List[suite_types.TestSuiteItem]:
return [
_apply_key_mapping(d, keys_mapping, ignore_keys) # type: ignore[misc]
for d in item_dicts
]
def from_json(
value: str,
keys_mapping: Dict[str, str],
ignore_keys: List[str],
) -> List[suite_types.TestSuiteItem]:
"""Parse a JSON array string into a list of TestSuiteItem dicts."""
parsed = json.loads(value)
if not isinstance(parsed, list):
raise ValueError(
f"JSON input must be an array of objects, got {type(parsed).__name__}."
)
return _from_dicts(parsed, keys_mapping, ignore_keys)
def from_pandas(
dataframe: "pd.DataFrame",
keys_mapping: Dict[str, str],
ignore_keys: List[str],
) -> List[suite_types.TestSuiteItem]:
"""Convert pandas DataFrame rows into a list of TestSuiteItem dicts."""
helpers.raise_if_pandas_is_unavailable()
items: List[suite_types.TestSuiteItem] = []
for record in dataframe.to_dict(orient="records"):
mapped: Dict[str, Any] = {}
for key, value in record.items():
if key in ignore_keys:
continue
# pandas stores missing optional fields as float NaN
if isinstance(value, float) and value != value:
continue
mapped[keys_mapping.get(key, key)] = value
items.append(mapped) # type: ignore[arg-type]
return items
def from_jsonl_file(
file_path: str,
keys_mapping: Dict[str, str],
ignore_keys: List[str],
) -> List[suite_types.TestSuiteItem]:
"""Read a JSONL file into a list of TestSuiteItem dicts."""
raw_items: List[Dict[str, Any]] = []
with open(file_path, "r", encoding="utf-8") as file:
for line in file:
line = line.strip()
if line:
raw_items.append(json.loads(line))
return _from_dicts(raw_items, keys_mapping, ignore_keys)
@@ -0,0 +1,163 @@
"""Console display for test suite results."""
from __future__ import annotations
from collections import defaultdict
from typing import Dict, Optional, TYPE_CHECKING
from rich import align, console, panel, table, text
from ..test_suite_result import is_score_passed
if TYPE_CHECKING:
from .. import test_suite_result as _result_mod
def _format_time(seconds: float) -> str:
hours, remainder = divmod(seconds, 3600)
minutes, seconds = divmod(remainder, 60)
return f"{int(hours):02d}:{int(minutes):02d}:{int(seconds):02d}"
def display_suite_results(
suite_result: _result_mod.TestSuiteResult,
verbose: int,
report_path: Optional[str],
) -> None:
suite_name = suite_result.suite_name or "Test Suite"
total_time = suite_result.total_time or 0.0
experiment_url = suite_result.experiment_url
test_results = [
test_result
for item_result in suite_result.item_results.values()
for test_result in item_result.test_results
]
nb_runs = len(test_results)
nb_items = suite_result.items_total
items_passed = suite_result.items_passed
items_total = suite_result.items_total
suite_passed = suite_result.all_items_passed
assertion_passed_count: Dict[str, int] = defaultdict(int)
assertion_total_count: Dict[str, int] = defaultdict(int)
for test_result_ in test_results:
for score in test_result_.score_results:
assertion_total_count[score.name] += 1
if is_score_passed(score):
assertion_passed_count[score.name] += 1
time_text = text.Text(f"Total time: {_format_time(total_time)}")
time_text.stylize("bold", 0, 18)
time_text = align.Align.left(time_text)
nb_samples_text = text.Text(f"Number of items: {nb_items:,} ({nb_runs:,} runs)")
nb_samples_text.stylize("bold", 0, 18)
nb_samples_text = align.Align.left(nb_samples_text)
pass_rate = items_passed / items_total if items_total > 0 else None
if items_total == 0:
pass_style = "yellow bold"
pass_label = "NO ITEMS"
elif suite_passed:
pass_style = "green bold"
pass_label = "PASSED"
else:
pass_style = "red bold"
pass_label = "FAILED"
pass_text = text.Text(f"Suite result: {pass_label}", style=pass_style)
pass_text.stylize("bold", 0, 18)
pass_text = align.Align.left(pass_text)
items_text = text.Text(f"Items passed: {items_passed}/{items_total}")
items_text.stylize("bold", 0, 18)
items_text = align.Align.left(items_text)
rate_value = f"{pass_rate:.1%}" if pass_rate is not None else "N/A"
rate_text = text.Text(f"Pass rate: {rate_value}")
rate_text.stylize("bold", 0, 18)
rate_text = align.Align.left(rate_text)
task_times = [
tr.task_execution_time
for tr in test_results
if tr.task_execution_time is not None
]
scoring_times = [
tr.scoring_time for tr in test_results if tr.scoring_time is not None
]
content = table.Table.grid()
content.add_row(text.Text(""))
if experiment_url is not None:
link_text = text.Text.from_markup(
f"[bold cyan][link={experiment_url}]View results in Opik dashboard[/link][/bold cyan]"
)
content.add_row(align.Align.left(link_text))
if report_path is not None:
report_text = text.Text.from_markup(
f"[bold green][link=file://{report_path}]View local detailed report file[/link][/bold green]"
)
content.add_row(align.Align.left(report_text))
if experiment_url is not None or report_path is not None:
content.add_row(text.Text(""))
content.add_row(time_text)
content.add_row(nb_samples_text)
content.add_row(pass_text)
content.add_row(items_text)
content.add_row(rate_text)
if task_times and scoring_times:
avg_task = sum(task_times) / len(task_times)
avg_scoring = sum(scoring_times) / len(scoring_times)
avg_total = avg_task + avg_scoring
avg_task_text = text.Text(f"Avg task time: {avg_task:.2f}s")
avg_task_text.stylize("bold", 0, 18)
avg_scoring_text = text.Text(f"Avg scoring time: {avg_scoring:.2f}s")
avg_scoring_text.stylize("bold", 0, 18)
avg_total_text = text.Text(f"Avg total time: {avg_total:.2f}s")
avg_total_text.stylize("bold", 0, 18)
content.add_row(text.Text(""))
content.add_row(align.Align.left(avg_task_text))
content.add_row(align.Align.left(avg_scoring_text))
content.add_row(align.Align.left(avg_total_text))
if verbose >= 2 and assertion_total_count:
sorted_assertions = sorted(
assertion_total_count.keys(),
key=lambda n: (
assertion_passed_count[n] / assertion_total_count[n]
if assertion_total_count[n] > 0
else 0.0
),
)
score_strings = text.Text("")
for name in sorted_assertions:
passed = assertion_passed_count[name]
total = assertion_total_count[name]
rate = passed / total if total > 0 else 0.0
style = "green bold" if passed == total else "red bold"
score_strings += text.Text(
f"{name}: {rate:.0%} passed ({passed}/{total})\n",
style=style,
)
content.add_row(text.Text(""))
content.add_row(align.Align.left(score_strings))
panel_content = panel.Panel(
content,
title=f"{suite_name} ({nb_items} items, {nb_runs} runs)",
title_align="left",
expand=False,
)
console_container = console.Console()
console_container.print(panel_content)
@@ -0,0 +1,59 @@
"""Save structured JSON report files for test suite results."""
from __future__ import annotations
import json
import logging
import os
from typing import Optional, TYPE_CHECKING
if TYPE_CHECKING:
from .. import test_suite_result as _result_mod
LOGGER = logging.getLogger(__name__)
DEFAULT_REPORT_DIR = "opik_test_suite_reports"
def save_report(
suite_result: _result_mod.TestSuiteResult,
output_path: Optional[str] = None,
) -> str:
"""Save a test suite result as a structured JSON report file.
Args:
suite_result: The test suite result to serialize.
output_path: Optional file path. If not provided, a default path
is generated under the ``opik_test_suite_reports/`` directory.
Returns:
The absolute path to the written report file.
"""
report_dict = suite_result.to_report_dict()
if output_path is None:
output_path = build_default_report_path(
suite_result.experiment_name or suite_result.experiment_id
)
output_path = os.path.abspath(output_path)
parent_dir = os.path.dirname(output_path)
if parent_dir:
os.makedirs(parent_dir, exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
json.dump(report_dict, f, indent=2, default=str, ensure_ascii=False)
LOGGER.debug("Test suite report saved to %s", output_path)
return output_path
def build_default_report_path(experiment_name: str) -> str:
"""Build a default report file path from an experiment name."""
safe_name = _sanitize_filename(experiment_name)
return os.path.abspath(os.path.join(DEFAULT_REPORT_DIR, f"{safe_name}.json"))
def _sanitize_filename(name: str) -> str:
"""Replace characters that are unsafe in file names."""
return "".join(c if c.isalnum() or c in "-_." else "_" for c in name)
@@ -0,0 +1,93 @@
"""
Suite result construction logic for test suites.
This module handles building TestSuiteResult from raw evaluation results,
including pass/fail determination based on execution policies.
"""
from collections import defaultdict
from typing import Dict, List, Optional
from opik.api_objects.dataset import dataset_item
from opik.evaluation import evaluation_result, test_result
from . import types as suite_types
from .test_suite_result import is_score_passed
def build_suite_result(
eval_result: evaluation_result.EvaluationResult,
suite_name: Optional[str] = None,
total_time: Optional[float] = None,
) -> suite_types.TestSuiteResult:
"""
Build a TestSuiteResult from an EvaluationResult.
Groups test results by dataset item and computes pass/fail status
based on execution policies stored in each item.
Pass/fail logic:
- A RUN passes if all its assertion scores pass (value=True or value=1)
- An ITEM passes if runs_passed >= pass_threshold
- The SUITE passes if all items pass
Args:
eval_result: The raw evaluation result from the evaluation engine.
Returns:
TestSuiteResult with pass/fail status for each item and the suite.
"""
results_by_item: Dict[str, List[test_result.TestResult]] = defaultdict(list)
items_cache: Dict[str, Optional[dataset_item.DatasetItem]] = {}
for result in eval_result.test_results:
item_id = result.test_case.dataset_item_id
results_by_item[item_id].append(result)
if item_id not in items_cache:
items_cache[item_id] = result.test_case.dataset_item
item_results: Dict[str, suite_types.ItemResult] = {}
items_passed = 0
for item_id, item_test_results in results_by_item.items():
item = items_cache.get(item_id)
pass_threshold = 1
configured_runs_per_item = 1
if item is not None and item.execution_policy is not None:
if item.execution_policy.pass_threshold is not None:
pass_threshold = item.execution_policy.pass_threshold
if item.execution_policy.runs_per_item is not None:
configured_runs_per_item = item.execution_policy.runs_per_item
has_assertions = any(r.score_results for r in item_test_results)
runs_passed = sum(
1
for r in item_test_results
if not r.score_results or all(is_score_passed(s) for s in r.score_results)
)
passed = runs_passed >= pass_threshold
if passed:
items_passed += 1
item_results[item_id] = suite_types.ItemResult(
dataset_item_id=item_id,
passed=passed,
has_assertions=has_assertions,
runs_passed=runs_passed,
runs_total=len(item_test_results),
configured_runs_per_item=configured_runs_per_item,
pass_threshold=pass_threshold,
test_results=sorted(item_test_results, key=lambda r: r.trial_id),
)
return suite_types.TestSuiteResult(
items_passed=items_passed,
items_total=len(results_by_item),
item_results=item_results,
evaluation_result_=eval_result,
suite_name=suite_name,
total_time=total_time,
)
@@ -0,0 +1,741 @@
"""
Test Suite API for regression testing LLM applications.
Test Suites are pre-configured regression test suites that let you
validate that prompt changes, model updates, or code modifications don't
break existing functionality.
"""
from __future__ import annotations
import logging
from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
import pandas as pd
from opik.api_objects import opik_client as opik_client_module
from opik.evaluation.suite_evaluators.llm_judge import LLMJudge
import datetime
from opik import exceptions as opik_exceptions
from opik.api_objects.prompt import base_prompt
from opik.api_objects.dataset import dataset
from opik.rest_api.types import dataset_version_public
from . import types as suite_types, converters
from .. import validators, execution_policy, rest_operations
LOGGER = logging.getLogger(__name__)
LLMTask = Callable[[Dict[str, Any]], Any]
def _evaluators_equal(a: List[LLMJudge], b: List[LLMJudge]) -> bool:
"""Compare two lists of LLMJudge evaluators by their assertion sets."""
a_assertions = sorted(assertion for e in a for assertion in e.assertions)
b_assertions = sorted(assertion for e in b for assertion in e.assertions)
return a_assertions == b_assertions
def validate_task_result(
result: Any,
input_data: Any = None,
) -> Dict[str, Any]:
"""Normalise the value returned by a task function into a result dict.
If *result* is already a :class:`dict`, it is returned as-is (the
supported keys are ``"input"`` and ``"output"``).
For any other type the value is wrapped automatically::
{"output": result}
When *input_data* is also provided the wrapper becomes::
{"input": input_data, "output": result}
Args:
result: Value returned by the task callable.
input_data: Optional input that was passed to the task. Included in
the wrapper dict as ``"input"`` when *result* is not a dict.
Returns:
A dict suitable for use as an experiment trace result.
"""
if isinstance(result, dict):
missing = {"input", "output"} - result.keys()
if missing:
raise ValueError(
f"The task function must return a dict with 'input' and "
f"'output' keys, but the returned dict is missing: "
f"{missing}. Got keys: {set(result.keys())}. "
f"Example: return {{'input': data, 'output': response}}"
)
return result
wrapped: Dict[str, Any] = {"output": result}
if input_data is not None:
wrapped["input"] = input_data
return wrapped
class TestSuiteVersion:
"""
A read-only view of a specific test suite version.
Provides access to suite items, assertions, and execution policy at a
specific version point in time. Does not allow mutations.
Obtain an instance via :meth:`TestSuite.get_version_view`.
"""
def __init__(
self,
name: str,
dataset_version: dataset.DatasetVersion,
version_info: dataset_version_public.DatasetVersionPublic,
) -> None:
self._name = name
self._dataset_version = dataset_version
self._version_info = version_info
@property
def name(self) -> str:
"""The name of the test suite this version belongs to."""
return self._name
@property
def id(self) -> str:
"""The dataset ID of the test suite."""
return self._dataset_version.dataset_id
@property
def version_name(self) -> Optional[str]:
"""The sequential version name (e.g., 'v1', 'v2')."""
return self._version_info.version_name
@property
def version_id(self) -> Optional[str]:
"""The unique identifier of this specific version."""
return self._version_info.id
@property
def is_latest(self) -> Optional[bool]:
"""Whether this is the latest version."""
return self._version_info.is_latest
@property
def items_total(self) -> Optional[int]:
"""Total number of items in this version."""
return self._version_info.items_total
@property
def version_hash(self) -> Optional[str]:
"""The unique hash identifier of this version."""
return self._version_info.version_hash
@property
def tags(self) -> Optional[List[str]]:
"""Tags associated with this version."""
return self._version_info.tags
@property
def items_added(self) -> Optional[int]:
"""Number of items added since the previous version."""
return self._version_info.items_added
@property
def items_modified(self) -> Optional[int]:
"""Number of items modified since the previous version."""
return self._version_info.items_modified
@property
def items_deleted(self) -> Optional[int]:
"""Number of items deleted since the previous version."""
return self._version_info.items_deleted
@property
def change_description(self) -> Optional[str]:
"""Description of changes in this version."""
return self._version_info.change_description
@property
def created_at(self) -> Optional[datetime.datetime]:
"""Timestamp when this version was created."""
return self._version_info.created_at
@property
def created_by(self) -> Optional[str]:
"""User who created this version."""
return self._version_info.created_by
@property
def project_name(self) -> Optional[str]:
"""The project name associated with the test suite."""
return self._dataset_version.project_name
@property
def __internal_api__dataset_version__(self) -> dataset.DatasetVersion:
"""Internal access to the underlying dataset version. Not part of the public API."""
return self._dataset_version
def get_items(
self,
nb_samples: Optional[int] = None,
filter_string: Optional[str] = None,
) -> List[suite_types.TestSuiteItem]:
"""
Retrieve suite items at this version as a list of dictionaries.
Args:
nb_samples: Maximum number of items to retrieve.
filter_string: Optional OQL filter string.
Returns:
A list of item dicts with keys: id, data, description,
assertions, execution_policy.
"""
return [
converters.dataset_item_to_suite_item_dict(item)
for item in self._dataset_version.__internal_api__stream_items_as_dataclasses__(
nb_samples=nb_samples,
filter_string=filter_string,
)
]
def get_global_assertions(self) -> List[str]:
"""
Get the suite-level assertions stored in this version.
Returns:
List of assertion strings.
"""
return converters.version_evaluators_to_assertions(
self._version_info.evaluators
)
def get_global_execution_policy(self) -> execution_policy.ExecutionPolicy:
"""
Get the suite-level execution policy stored in this version.
Returns:
ExecutionPolicy dict with runs_per_item and pass_threshold.
"""
return converters.version_policy_to_execution_policy(
self._version_info.execution_policy
)
class TestSuite:
"""
A pre-configured regression test suite for LLM applications.
Test Suites let you:
- Define test cases with inputs and context
- Configure assertions that will be checked by an LLM
- Run tests against any task function
Suite-level assertions and execution policy are stored in the dataset's
metadata and read by the evaluation engine when running the suite.
Example:
>>> import opik
>>>
>>> client = opik.Opik()
>>>
>>> suite = client.create_test_suite(
... name="Refund Policy Tests",
... description="Regression tests for refund scenarios",
... global_assertions=[
... "Response does not contain hallucinated information",
... "Response is helpful to the user",
... ],
... )
>>>
>>> suite.insert([
... {
... "data": {"user_input": "How do I get a refund?", "user_tier": "premium"},
... "assertions": ["Response is polite"],
... },
... ])
>>>
>>> results = opik.run_tests(test_suite=suite, task=my_llm_function)
"""
def __init__(
self,
name: str,
dataset_: dataset.Dataset,
client: Optional["opik_client_module.Opik"] = None,
):
"""
Internal constructor — not part of the public API.
Use :meth:`opik.Opik.create_test_suite` or
:meth:`opik.Opik.get_or_create_test_suite` instead.
"""
self._name = name
self._dataset = dataset_
self._client = client
@property
def id(self) -> str:
"""The ID of the test suite."""
return self._dataset.id
@property
def name(self) -> str:
"""The name of the test suite."""
return self._name
@property
def description(self) -> Optional[str]:
"""The description of the test suite."""
return self._dataset.description
@property
def project_name(self) -> Optional[str]:
"""The project name associated with the test suite."""
return self._dataset.project_name
@property
def __internal_api__dataset__(self) -> dataset.Dataset:
"""Internal access to the underlying dataset. Not part of the public API."""
return self._dataset
@property
def items_count(self) -> Optional[int]:
"""The total number of items in the test suite."""
return self._dataset.dataset_items_count
def get_tags(self) -> List[str]:
"""
Get the tags for the suite.
Returns:
List of tag strings.
"""
return self._dataset.get_tags()
def get_current_version_name(self) -> Optional[str]:
"""
Get the current version name of the test suite.
Returns:
The current version name (e.g., 'v1', 'v2'), or None if
no version exists.
"""
return self._dataset.get_current_version_name()
def get_version_info(
self,
) -> Optional[dataset_version_public.DatasetVersionPublic]:
"""
Get version information for the current (latest) version.
Returns:
DatasetVersionPublic containing the current version's metadata,
or None if no version exists yet.
"""
return self._dataset.get_version_info()
def get_version_view(self, version_name: str) -> TestSuiteVersion:
"""
Get a read-only view of a specific version.
Args:
version_name: The version name (e.g., 'v1', 'v2').
Returns:
A read-only TestSuiteVersion for accessing the specified
version's items, assertions, and execution policy.
Raises:
opik.exceptions.DatasetVersionNotFound: If the version does not
exist.
"""
version_info = rest_operations.find_version_by_name(
rest_client=self._dataset._rest_client,
dataset_id=self._dataset.id,
version_name=version_name,
)
if version_info is None:
raise opik_exceptions.DatasetVersionNotFound(
f"Version '{version_name}' not found in test suite '{self._name}'"
)
dataset_version = dataset.DatasetVersion(
dataset_name=self._name,
dataset_id=self._dataset.id,
rest_client=self._dataset._rest_client,
version_info=version_info,
project_name=self._dataset.project_name,
client=self._dataset.client,
)
return TestSuiteVersion(
name=self._name,
dataset_version=dataset_version,
version_info=version_info,
)
def get_items(
self,
nb_samples: Optional[int] = None,
filter_string: Optional[str] = None,
) -> List[suite_types.TestSuiteItem]:
"""
Retrieve suite items as a list of dictionaries.
Each item dict has keys: ``id``, ``data``, ``description``,
``assertions``, ``execution_policy``.
Args:
nb_samples: Maximum number of items to retrieve.
If None, all items are returned.
filter_string: Optional OQL filter string to filter items.
Returns:
A list of item dictionaries.
"""
return [
converters.dataset_item_to_suite_item_dict(item)
for item in self._dataset.__internal_api__stream_items_as_dataclasses__(
nb_samples=nb_samples,
filter_string=filter_string,
)
]
def to_pandas(self) -> "pd.DataFrame":
"""
Convert the test suite items to a pandas DataFrame.
Requires the ``pandas`` library to be installed.
Returns:
A pandas DataFrame containing all items with columns
such as ``id``, ``data``, ``assertions``, ``description``,
and ``execution_policy``.
"""
return converters.to_pandas(self.get_items())
def to_json(self) -> str:
"""
Convert the test suite items to a JSON string.
Returns:
A JSON string representation of all items.
"""
return converters.to_json(self.get_items())
def insert_from_json(
self,
json_array: str,
keys_mapping: Optional[Dict[str, str]] = None,
ignore_keys: Optional[List[str]] = None,
) -> None:
"""
Insert test suite items from a JSON string.
Each JSON object must map to valid test suite item keys after
applying ``keys_mapping``:
- ``data`` (required) — dict of test case inputs
- ``assertions`` — list of assertion strings
- ``description`` — item description
- ``execution_policy`` — dict with ``runs_per_item`` and ``pass_threshold``
- ``id`` — item identifier (auto-generated if omitted)
Args:
json_array: JSON string of format ``[{...}, {...}]``.
keys_mapping: Maps JSON keys to the target keys listed above.
Example: ``{"test_data": "data", "checks": "assertions"}``
ignore_keys: Keys in the JSON dicts to skip during import.
"""
keys_mapping = {} if keys_mapping is None else keys_mapping
ignore_keys = [] if ignore_keys is None else ignore_keys
self.insert(converters.from_json(json_array, keys_mapping, ignore_keys))
def insert_from_pandas(
self,
dataframe: "pd.DataFrame",
keys_mapping: Optional[Dict[str, str]] = None,
ignore_keys: Optional[List[str]] = None,
) -> None:
"""
Insert test suite items from a pandas DataFrame.
Requires the ``pandas`` library to be installed.
Each DataFrame row must map to valid test suite item keys after
applying ``keys_mapping``:
- ``data`` (required) — dict of test case inputs
- ``assertions`` — list of assertion strings
- ``description`` — item description
- ``execution_policy`` — dict with ``runs_per_item`` and ``pass_threshold``
- ``id`` — item identifier (auto-generated if omitted)
Args:
dataframe: pandas DataFrame.
keys_mapping: Maps column names to the target keys listed above.
Example: ``{"test_data": "data", "checks": "assertions"}``
ignore_keys: Column names in the DataFrame to skip during import.
"""
keys_mapping = {} if keys_mapping is None else keys_mapping
ignore_keys = [] if ignore_keys is None else ignore_keys
self.insert(converters.from_pandas(dataframe, keys_mapping, ignore_keys))
def insert_from_jsonl_file(
self,
file_path: str,
keys_mapping: Optional[Dict[str, str]] = None,
ignore_keys: Optional[List[str]] = None,
) -> None:
"""
Read JSONL from a file and insert items into the test suite.
Each line must be a JSON object that maps to valid test suite item
keys after applying ``keys_mapping``:
- ``data`` (required) — dict of test case inputs
- ``assertions`` — list of assertion strings
- ``description`` — item description
- ``execution_policy`` — dict with ``runs_per_item`` and ``pass_threshold``
- ``id`` — item identifier (auto-generated if omitted)
Args:
file_path: Path to the JSONL file.
keys_mapping: Maps JSON keys to the target keys listed above.
Example: ``{"test_data": "data", "checks": "assertions"}``
ignore_keys: Keys in the JSON objects to skip during import.
"""
keys_mapping = {} if keys_mapping is None else keys_mapping
ignore_keys = [] if ignore_keys is None else ignore_keys
self.insert(converters.from_jsonl_file(file_path, keys_mapping, ignore_keys))
def update_test_settings(
self,
*,
global_execution_policy: Optional[execution_policy.ExecutionPolicy] = None,
global_assertions: Optional[List[str]] = None,
) -> None:
"""
Update the suite-level assertions and/or execution policy.
Supports partial updates: any parameter not provided will retain
its current value. If the new values are identical to the current
values, no new version is created.
Args:
global_execution_policy: New execution policy for the suite.
If not provided, the current policy is kept.
global_assertions: New suite-level assertions. Each string
describes an expected behavior that will be checked by an
LLM. If not provided, the current assertions are kept.
Raises:
ValueError: If nothing to update is provided.
"""
if global_execution_policy is not None:
validators.validate_execution_policy(global_execution_policy)
resolved = validators.resolve_evaluators(
global_assertions, None, "suite-level assertions"
)
if resolved is None and global_execution_policy is None:
raise ValueError(
"At least one of 'global_assertions' or "
"'global_execution_policy' must be provided."
)
version_info = self._dataset.get_version_info()
if version_info is None:
new_evaluators = resolved or []
new_policy = (
global_execution_policy
or execution_policy.DEFAULT_EXECUTION_POLICY.copy()
)
rest_operations.create_initial_test_suite_version(
rest_client=self._dataset._rest_client,
dataset_id=self._dataset.id,
evaluators=new_evaluators,
exec_policy=new_policy,
)
return
current_evaluators = self._dataset.get_evaluators()
current_policy = self.get_global_execution_policy()
new_evaluators = resolved if resolved is not None else current_evaluators
new_policy = (
global_execution_policy
if global_execution_policy is not None
else current_policy
)
if (
_evaluators_equal(new_evaluators, current_evaluators)
and new_policy == current_policy
):
return
change_parts: List[str] = []
if resolved is not None:
change_parts.append("assertions")
if global_execution_policy is not None:
change_parts.append("execution policy")
rest_operations.update_test_suite_dataset(
rest_client=self._dataset._rest_client,
dataset_id=self._dataset.id,
base_version_id=version_info.id,
evaluators=new_evaluators,
exec_policy=new_policy,
change_description=f"Updated {' and '.join(change_parts)} via SDK",
)
def delete(self, items_ids: List[str]) -> None:
"""
Delete items from the test suite by their IDs.
Args:
items_ids: List of item IDs to delete.
"""
self._dataset.delete(items_ids)
def clear(self) -> None:
"""
Delete all items from the test suite.
"""
item_ids = [
item.id
for item in self._dataset.__internal_api__stream_items_as_dataclasses__()
if item.id is not None
]
if item_ids:
self._dataset.delete(item_ids)
def get_global_execution_policy(self) -> execution_policy.ExecutionPolicy:
"""
Get the suite-level execution policy.
Returns:
ExecutionPolicy dict with runs_per_item and pass_threshold.
"""
return self._dataset.get_execution_policy()
def get_global_assertions(self) -> List[str]:
"""
Get the suite-level assertions.
Returns:
List of assertion strings.
"""
return converters.evaluators_to_assertions(self._dataset.get_evaluators())
def update(
self,
items: List[suite_types.TestSuiteItem],
) -> None:
"""
Update existing items in the test suite.
Each item dict must include an ``"id"`` key identifying the item to
update. The remaining keys (``"data"``, ``"assertions"``,
``"description"``, ``"execution_policy"``) replace the previous values.
Args:
items: List of item dicts to update. Each must contain ``"id"``.
Raises:
DatasetItemUpdateOperationRequiresItemId: If any item is missing
an ``"id"`` key.
"""
for item in items:
if "id" not in item:
raise opik_exceptions.DatasetItemUpdateOperationRequiresItemId(
"Missing id for test suite item to update: %s", item
)
self.insert(items)
def insert(
self,
items: List[suite_types.TestSuiteItem],
) -> None:
"""
Insert test cases into the test suite.
Args:
items: List of test case items to add.
Example:
>>> suite.insert([
... {"data": {"question": "How do I get a refund?"}},
... {
... "data": {"question": "Is my account hacked?"},
... "assertions": ["Response treats the concern with urgency"],
... "execution_policy": {"runs_per_item": 5, "pass_threshold": 4},
... },
... ])
"""
validators.validate_suite_items(items)
ds_items = [converters.suite_item_dict_to_dataset_item(item) for item in items]
self._dataset.__internal_api__insert_items_as_dataclasses__(ds_items)
def __internal_api__run_optimization_suite__(
self,
task: LLMTask,
*,
experiment_name_prefix: Optional[str] = None,
experiment_name: Optional[str] = None,
project_name: Optional[str] = None,
experiment_config: Optional[Dict[str, Any]] = None,
prompts: Optional[List[base_prompt.BasePrompt]] = None,
experiment_tags: Optional[List[str]] = None,
verbose: int = 2,
worker_threads: int = 16,
model: Optional[str] = None,
optimization_id: Optional[str] = None,
experiment_type: Optional[str] = None,
dataset_item_ids: Optional[List[str]] = None,
dataset_filter_string: Optional[str] = None,
client: Optional["opik_client_module.Opik"] = None,
generate_report: bool = True,
report_output_path: Optional[str] = None,
blueprint_id: Optional[str] = None,
) -> suite_types.TestSuiteResult:
"""
Internal entry point used by the optimizer framework.
"""
from opik.evaluation.evaluator import __internal_api__run_test_suite__
return __internal_api__run_test_suite__(
suite_dataset=self._dataset,
task=task,
client=client,
dataset_item_ids=dataset_item_ids,
dataset_filter_string=dataset_filter_string,
experiment_name_prefix=experiment_name_prefix,
experiment_name=experiment_name,
project_name=project_name,
experiment_config=experiment_config,
prompts=prompts,
experiment_tags=experiment_tags,
verbose=verbose,
task_threads=worker_threads,
evaluator_model=model,
optimization_id=optimization_id,
experiment_type=experiment_type,
generate_report=generate_report,
report_output_path=report_output_path,
blueprint_id=blueprint_id,
)
@@ -0,0 +1,218 @@
"""TestSuiteResult and ItemResult types."""
from __future__ import annotations
import dataclasses
from typing import Any, Dict, List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from opik.evaluation import evaluation_result, test_result
from opik.evaluation.metrics.score_result import ScoreResult
def is_score_passed(score: ScoreResult) -> bool:
"""Determine whether a score result represents a passing assertion."""
if score.scoring_failed:
return False
return (isinstance(score.value, bool) and score.value) or score.value == 1
@dataclasses.dataclass
class ItemResult:
"""Result for a single test suite item."""
dataset_item_id: str
"""The ID of the dataset item."""
passed: bool
"""Whether this item passed based on its execution policy."""
has_assertions: bool
"""Whether this item had any assertions evaluated."""
runs_passed: int
"""Number of runs that passed for this item."""
runs_total: int
"""Total number of runs completed for this item."""
configured_runs_per_item: int
"""Configured runs_per_item from the execution policy."""
pass_threshold: int
"""Minimum passing runs required (from execution policy)."""
test_results: List[test_result.TestResult]
"""Individual test results for each run of this item."""
class TestSuiteResult:
"""
Result of running a test suite.
Contains pass/fail status for each item based on execution policy,
as well as overall suite pass/fail status.
"""
def __init__(
self,
items_passed: int,
items_total: int,
item_results: Dict[str, ItemResult],
evaluation_result_: evaluation_result.EvaluationResult,
suite_name: Optional[str] = None,
total_time: Optional[float] = None,
) -> None:
self._items_passed = items_passed
self._items_total = items_total
self._item_results = item_results
self._evaluation_result = evaluation_result_
self._suite_name = suite_name
self._total_time = total_time
@property
def all_items_passed(self) -> bool:
"""Whether all items in the suite passed."""
return self._items_passed == self._items_total
@property
def items_passed(self) -> int:
"""Number of items that passed."""
return self._items_passed
@property
def items_total(self) -> int:
"""Total number of items evaluated."""
return self._items_total
@property
def item_results(self) -> Dict[str, ItemResult]:
"""Results for each item, keyed by dataset_item_id."""
return self._item_results
@property
def pass_rate(self) -> Optional[float]:
"""Pass rate among items that had assertions.
Items without any assertions are excluded from the calculation.
Returns None if no items had assertions.
"""
items_with_assertions = [
r for r in self._item_results.values() if r.has_assertions
]
if not items_with_assertions:
return None
passed = sum(1 for r in items_with_assertions if r.passed)
return passed / len(items_with_assertions)
@property
def suite_name(self) -> Optional[str]:
"""The name of the test suite."""
return self._suite_name
@property
def total_time(self) -> Optional[float]:
"""Total evaluation time in seconds."""
return self._total_time
@property
def experiment_id(self) -> str:
"""The experiment ID."""
return self._evaluation_result.experiment_id
@property
def experiment_name(self) -> Optional[str]:
"""The experiment name."""
return self._evaluation_result.experiment_name
@property
def experiment_url(self) -> Optional[str]:
"""URL to view the experiment."""
return self._evaluation_result.experiment_url
def to_report_dict(self) -> Dict[str, Any]:
"""Convert the result to a structured report dictionary."""
from datetime import datetime, timezone
items: List[Dict[str, Any]] = []
for item_id, item_result in self._item_results.items():
runs: List[Dict[str, Any]] = []
for test_result_ in item_result.test_results:
assertions: List[Dict[str, Any]] = []
for score in test_result_.score_results:
assertion: Dict[str, Any] = {
"name": score.name,
"passed": is_score_passed(score),
"value": score.value,
"scoring_failed": score.scoring_failed,
}
if score.reason is not None:
assertion["reason"] = score.reason
if score.metadata is not None:
assertion["metadata"] = score.metadata
assertions.append(assertion)
run_passed = (
all(a["passed"] for a in assertions) if assertions else True
)
run: Dict[str, Any] = {
"trial_id": test_result_.trial_id,
"passed": run_passed,
"input": test_result_.test_case.task_output.get("input"),
"output": test_result_.test_case.task_output.get("output"),
"assertions": assertions,
}
if test_result_.test_case.trace_id:
run["trace_id"] = test_result_.test_case.trace_id
if test_result_.task_execution_time is not None:
run["task_execution_time_seconds"] = round(
test_result_.task_execution_time, 3
)
if test_result_.scoring_time is not None:
run["scoring_time_seconds"] = round(test_result_.scoring_time, 3)
runs.append(run)
items.append(
{
"dataset_item_id": item_id,
"passed": item_result.passed,
"runs_passed": item_result.runs_passed,
"execution_policy": {
"runs_per_item": item_result.configured_runs_per_item,
"pass_threshold": item_result.pass_threshold,
},
"runs": runs,
}
)
report: Dict[str, Any] = {
"suite_passed": self.all_items_passed,
"items_passed": self._items_passed,
"items_total": self._items_total,
"pass_rate": self.pass_rate,
"experiment_id": self.experiment_id,
}
if self._suite_name is not None:
report["suite_name"] = self._suite_name
if self.experiment_name is not None:
report["experiment_name"] = self.experiment_name
if self.experiment_url is not None:
report["experiment_url"] = self.experiment_url
if self._total_time is not None:
report["total_time_seconds"] = round(self._total_time, 3)
report["generated_at"] = datetime.now(timezone.utc).isoformat()
report["items"] = items
return report
def to_dict(self) -> Dict[str, Any]:
"""Alias for to_report_dict()."""
return self.to_report_dict()
@@ -0,0 +1,25 @@
"""Type definitions for test suite."""
from __future__ import annotations
from typing import Any, Dict, List, TypedDict
from typing_extensions import Required
from ..execution_policy import ExecutionPolicy
from .test_suite_result import TestSuiteResult, ItemResult
__all__ = ["TestSuiteItem", "TestSuiteResult", "ItemResult"]
class TestSuiteItem(TypedDict, total=False):
"""A test case item for a test suite.
Used for both inserting new items and reading existing ones.
"""
id: str
data: Required[Dict[str, Any]]
assertions: List[str]
description: str
execution_policy: ExecutionPolicy
@@ -0,0 +1,135 @@
"""Validators for dataset and test suite operations."""
from __future__ import annotations
from typing import Any, List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from opik.evaluation.suite_evaluators.llm_judge import LLMJudge
def validate_evaluators(evaluators: List[Any], context: str) -> None:
"""
Validate that all evaluators are LLMJudge instances.
Args:
evaluators: List of evaluators to validate.
context: Description of where the evaluators are being used (for error message).
Raises:
TypeError: If any evaluator is not an LLMJudge instance.
"""
from opik.evaluation.suite_evaluators import llm_judge
for evaluator in evaluators:
if not isinstance(evaluator, llm_judge.LLMJudge):
raise TypeError(
f"Test suites only support LLMJudge evaluators. "
f"Got {type(evaluator).__name__} in {context}. "
f"Use LLMJudge from opik.evaluation.suite_evaluators instead."
)
def resolve_evaluators(
assertions: Optional[List[str]],
evaluators: Optional[List[Any]],
context: str,
) -> Optional[List[LLMJudge]]:
"""
Resolve assertions shorthand and/or evaluators into a list of LLMJudge instances.
Args:
assertions: List of assertion strings to build an LLMJudge from.
evaluators: List of pre-built LLMJudge evaluators.
context: Description of where this is used (for error messages).
Returns:
A list of LLMJudge instances, or None if neither was provided.
Raises:
ValueError: If both assertions and evaluators are provided.
TypeError: If any evaluator is not an LLMJudge instance.
"""
if assertions is not None and evaluators is not None:
raise ValueError(
f"Cannot specify both 'assertions' and 'evaluators' for {context}. "
f"Use 'assertions' for a shorthand or 'evaluators' for full control, "
f"but not both."
)
if assertions is not None:
if not assertions:
return []
from opik.evaluation.suite_evaluators import llm_judge
return [llm_judge.LLMJudge(assertions=assertions)]
if evaluators is not None:
validate_evaluators(evaluators, context)
return evaluators
return None
_VALID_ITEM_KEYS = {"id", "data", "assertions", "description", "execution_policy"}
_VALID_EXECUTION_POLICY_KEYS = {"runs_per_item", "pass_threshold"}
def validate_execution_policy(ep: Any, context: str = "execution_policy") -> None:
if not isinstance(ep, dict):
raise TypeError(f"'{context}' must be a dict, got {type(ep).__name__}")
unknown_keys = set(ep.keys()) - _VALID_EXECUTION_POLICY_KEYS
if unknown_keys:
raise ValueError(
f"'{context}' has unknown keys: {unknown_keys}. "
f"Valid keys are: {_VALID_EXECUTION_POLICY_KEYS}"
)
missing_keys = _VALID_EXECUTION_POLICY_KEYS - set(ep.keys())
if missing_keys:
raise ValueError(
f"'{context}' is missing required keys: {missing_keys}. "
f"Both 'runs_per_item' and 'pass_threshold' must be provided."
)
for key in ep:
if not isinstance(ep[key], int):
raise TypeError(
f"'{context}.{key}' must be an int, got {type(ep[key]).__name__}"
)
def validate_suite_items(items: List[Any]) -> None:
for i, item in enumerate(items):
if not isinstance(item, dict):
raise TypeError(
f"Item at index {i} must be a dict, got {type(item).__name__}"
)
unknown_keys = set(item.keys()) - _VALID_ITEM_KEYS
if unknown_keys:
raise ValueError(
f"Item at index {i} has unknown keys: {unknown_keys}. "
f"Valid keys are: {_VALID_ITEM_KEYS}"
)
if "data" not in item:
raise ValueError(f"Item at index {i} is missing required key 'data'")
if not isinstance(item["data"], dict):
raise TypeError(
f"Item at index {i} 'data' must be a dict, "
f"got {type(item['data']).__name__}"
)
if "assertions" in item:
assertions = item["assertions"]
if not isinstance(assertions, list) or not all(
isinstance(a, str) for a in assertions
):
raise TypeError(
f"Item at index {i} 'assertions' must be a list of strings"
)
if "execution_policy" in item:
validate_execution_policy(
item["execution_policy"],
context=f"Item at index {i} 'execution_policy'",
)
@@ -0,0 +1,8 @@
from .experiment import Experiment
from .helpers import build_metadata_and_prompt_versions, handle_prompt_args
__all__ = [
"Experiment",
"build_metadata_and_prompt_versions",
"handle_prompt_args",
]
@@ -0,0 +1,171 @@
import functools
import logging
from typing import List, Optional, TYPE_CHECKING
from opik.message_processing.batching import sequence_splitter
from opik.message_processing import messages, streamer
from opik.rest_api import client as rest_api_client
from opik.rest_api import types as rest_api_types
from . import experiment_item, experiments_client
from .. import constants, helpers
from ...api_objects.prompt import base_prompt
if TYPE_CHECKING:
from opik.evaluation.metrics import score_result
LOGGER = logging.getLogger(__name__)
class Experiment:
def __init__(
self,
id: str,
name: Optional[str],
dataset_name: str,
rest_client: rest_api_client.OpikApi,
streamer: streamer.Streamer,
experiments_client: experiments_client.ExperimentsClient,
prompts: Optional[List[base_prompt.BasePrompt]] = None,
tags: Optional[List[str]] = None,
project_name: Optional[str] = None,
) -> None:
self._id = id
self._name = name
self._dataset_name = dataset_name
self._rest_client = rest_client
self._prompts = prompts
self._streamer = streamer
self._experiments_client = experiments_client
self._tags = tags
self._project_name = project_name
@property
def project_name(self) -> Optional[str]:
return self._project_name
@property
def id(self) -> str:
return self._id
@property
def dataset_name(self) -> str:
return self._dataset_name
@property
def name(self) -> str:
if self._name is not None:
return self._name
name = self._rest_client.experiments.get_experiment_by_id(id=self.id).name
self._name = name
return name
@property
def tags(self) -> Optional[List[str]]:
return self._tags
@property
def prompts(self) -> Optional[List[base_prompt.BasePrompt]]:
return self._prompts
@functools.cached_property
def dataset_id(self) -> str:
return self._rest_client.datasets.get_dataset_by_identifier(
dataset_name=self._dataset_name
).id
@property
def experiments_rest_client(self) -> rest_api_client.ExperimentsClient:
return self._rest_client.experiments
def get_experiment_data(self) -> rest_api_types.experiment_public.ExperimentPublic:
return self._rest_client.experiments.get_experiment_by_id(id=self.id)
def insert(
self,
experiment_items_references: List[experiment_item.ExperimentItemReferences],
) -> None:
"""
Creates a new experiment item by linking the existing trace and dataset item.
Args:
experiment_items_references: The list of ExperimentItemReferences objects, containing
trace id and dataset item id to link together into experiment item.
Returns:
None
"""
experiment_item_messages = [
messages.ExperimentItemMessage(
id=helpers.generate_id(),
experiment_id=self.id,
dataset_item_id=item.dataset_item_id,
trace_id=item.trace_id,
project_name=item.project_name,
execution_policy=item.execution_policy,
)
for item in experiment_items_references
]
# Split into batches for the streamer
batches = sequence_splitter.split_into_batches(
experiment_item_messages,
max_length=constants.FEEDBACK_SCORES_MAX_BATCH_SIZE,
)
for batch in batches:
create_experiment_items_batch_message = (
messages.CreateExperimentItemsBatchMessage(batch=batch)
)
self._streamer.put(create_experiment_items_batch_message)
def get_items(
self,
max_results: Optional[int] = 10000,
truncate: bool = False,
) -> List[experiment_item.ExperimentItemContent]:
"""
Retrieves and returns a list of experiment items for this experiment.
Args:
max_results: Maximum number of experiment items to retrieve. Defaults to 10000 if not specified.
truncate: Whether to truncate the items returned by the backend. Defaults to False.
Returns:
List of ExperimentItemContent objects for this experiment.
"""
if max_results is None:
max_results = 10000 # TODO: remove this once we have a proper way to get all experiment items
return self._experiments_client.find_experiment_items_for_dataset(
dataset_name=self.dataset_name,
experiment_ids=[self.id],
truncate=truncate,
max_results=max_results,
project_name=self._project_name,
)
def log_experiment_scores(
self,
score_results: List["score_result.ScoreResult"],
) -> None:
"""Log experiment-level scores to the backend."""
experiment_scores: List[rest_api_types.ExperimentScore] = []
for score_result_ in score_results:
if score_result_.scoring_failed:
continue
experiment_score = rest_api_types.ExperimentScore(
name=score_result_.name,
value=score_result_.value,
)
experiment_scores.append(experiment_score)
if experiment_scores:
self._rest_client.experiments.update_experiment(
id=self.id,
experiment_scores=experiment_scores,
)
@@ -0,0 +1,67 @@
import dataclasses
from typing import Dict, Any, List, Optional
from opik.types import FeedbackScoreDict
from opik.rest_api.types import experiment_item_compare
AssertionResultDict = Dict[str, Any]
@dataclasses.dataclass
class ExperimentItemReferences:
dataset_item_id: str
trace_id: str
project_name: Optional[str] = None
execution_policy: Optional[Dict[str, Any]] = None
@dataclasses.dataclass
class ExperimentItemContent:
id: str
dataset_item_id: str
trace_id: str
dataset_item_data: Optional[Dict[str, Any]]
evaluation_task_output: Optional[Dict[str, Any]]
feedback_scores: List[FeedbackScoreDict]
assertion_results: List[AssertionResultDict] = dataclasses.field(
default_factory=list
)
@classmethod
def from_rest_experiment_item_compare(
cls,
value: experiment_item_compare.ExperimentItemCompare,
dataset_item_data: Optional[Dict[str, Any]] = None,
) -> "ExperimentItemContent":
if value.feedback_scores is None:
feedback_scores: List[FeedbackScoreDict] = []
else:
feedback_scores = [
{
"category_name": rest_feedback_score.category_name,
"name": rest_feedback_score.name,
"reason": rest_feedback_score.reason,
"value": rest_feedback_score.value,
}
for rest_feedback_score in value.feedback_scores
]
if value.assertion_results is None:
assertion_results: List[AssertionResultDict] = []
else:
assertion_results = [
ar
if isinstance(ar, dict)
else {"value": ar.value, "passed": ar.passed, "reason": ar.reason}
for ar in value.assertion_results
]
return ExperimentItemContent(
id=value.id,
trace_id=value.trace_id,
dataset_item_id=value.dataset_item_id,
dataset_item_data=dataset_item_data if dataset_item_data else value.input,
evaluation_task_output=value.output,
feedback_scores=feedback_scores,
assertion_results=assertion_results,
)

Some files were not shown because too many files have changed in this diff Show More