Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51354e6c6d | ||
|
|
10dd50cff0 | ||
|
|
d8783f8698 | ||
|
|
16a572f51d | ||
|
|
947641cbbf | ||
|
|
249e161473 | ||
|
|
90bab4386f | ||
|
|
33fddb1344 | ||
|
|
c24dcb068a | ||
|
|
2986723254 | ||
|
|
b45b2fd8f8 | ||
|
|
8936fb4ad9 | ||
|
|
7d7e25b39c | ||
|
|
138d2f1c09 | ||
|
|
51595eb15d | ||
|
|
6ee68cf508 | ||
|
|
308513e644 | ||
|
|
155543bc93 | ||
|
|
8f961925d5 | ||
|
|
7ddc844d7d | ||
|
|
5381c5f19b | ||
|
|
dcd6060665 | ||
|
|
0bfba7d690 | ||
|
|
25b363e1bc | ||
|
|
0de37b0437 | ||
|
|
ab5febb9c7 | ||
|
|
0e5cade9e7 | ||
|
|
1438e052df | ||
|
|
9917aff8cf | ||
|
|
c915c2b678 | ||
|
|
50379bd4b5 | ||
|
|
8fd93ecff9 | ||
|
|
046c6c6125 | ||
|
|
ba750a2d55 | ||
|
|
485120806e | ||
|
|
87acc4f7f3 | ||
|
|
21b1faa21b | ||
|
|
76cfaf1069 | ||
|
|
dbe808d520 | ||
|
|
6c977429c2 | ||
|
|
c42c6a0f91 | ||
|
|
f14b69f68d | ||
|
|
dd892459a4 | ||
|
|
3488b5c443 | ||
|
|
6e0f4483b4 | ||
|
|
b5d9fb3e63 | ||
|
|
e087da6b6e | ||
|
|
ea8734d709 | ||
|
|
9cf4e57da1 | ||
|
|
292501b6dd | ||
|
|
2a1424e4d0 |
@@ -55,6 +55,7 @@ OTEL_SERVICE_NAME="langfuse"
|
||||
|
||||
# Auth, optional configuration
|
||||
# AUTH_DOMAINS_WITH_SSO_ENFORCEMENT=domain1.com,domain2.com
|
||||
# AUTH_IGNORE_ACCOUNT_FIELDS=foo,bar
|
||||
# AUTH_DISABLE_USERNAME_PASSWORD=true
|
||||
# AUTH_DISABLE_SIGNUP=true
|
||||
# AUTH_SESSION_MAX_AGE=43200 # 30 days in minutes (default)
|
||||
@@ -100,6 +101,7 @@ OTEL_SERVICE_NAME="langfuse"
|
||||
# AUTH_CUSTOM_ISSUER=
|
||||
# AUTH_CUSTOM_NAME=
|
||||
# AUTH_CUSTOM_SCOPE="openid email profile" # optional
|
||||
# AUTH_CUSTOM_CLIENT_AUTH_METHOD="client_secret_basic" # optional
|
||||
# AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING=false
|
||||
|
||||
# Transactional email, optional
|
||||
|
||||
@@ -459,9 +459,9 @@ jobs:
|
||||
type=ref,event=pr
|
||||
type=sha
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v3') }}
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ !contains(github.ref, '-rc') }}
|
||||
type=semver,pattern={{major}},enable=${{ !contains(github.ref, '-rc') }}
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v3') && !contains(github.ref, '-rc') }}
|
||||
- name: Build and push Docker image (web)
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
@@ -487,9 +487,9 @@ jobs:
|
||||
type=ref,event=pr
|
||||
type=sha
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v3') }}
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ !contains(github.ref, '-rc') }}
|
||||
type=semver,pattern={{major}},enable=${{ !contains(github.ref, '-rc') }}
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v3') && !contains(github.ref, '-rc') }}
|
||||
- name: Build and push Docker image (worker)
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
|
||||
@@ -42,9 +42,7 @@
|
||||
|
||||
## Langfuse Overview
|
||||
|
||||
_Unmute video for voice-over_
|
||||
|
||||
https://github.com/langfuse/langfuse/assets/2834609/a94062e9-c782-4ee9-af59-dee6370149a8
|
||||
[](https://langfuse.com/watch-demo)
|
||||
|
||||
### Develop
|
||||
|
||||
@@ -194,37 +192,51 @@ You can opt-out by setting `TELEMETRY_ENABLED=false`.
|
||||
|
||||
### Open Source Projects Using Langfuse
|
||||
|
||||
Top open-source Python projects that use Langfuse, ranked by stars ([Source](https://github.com/langfuse/langfuse-docs/blob/main/components-mdx/dependents/python.md)):
|
||||
Top open-source Python projects that use Langfuse, ranked by stars ([Source](https://github.com/langfuse/langfuse-docs/blob/main/components-mdx/dependents)):
|
||||
|
||||
| Repository | Stars |
|
||||
| :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----: |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/127165244?s=40&v=4" width="20" height="20" alt=""> [langgenius](https://github.com/langgenius) / [dify](https://github.com/langgenius/dify) | 53464 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/158137808?s=40&v=4" width="20" height="20" alt=""> [open-webui](https://github.com/open-webui) / [open-webui](https://github.com/open-webui/open-webui) | 49387 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/130722866?s=40&v=4" width="20" height="20" alt=""> [run-llama](https://github.com/run-llama) / [llama_index](https://github.com/run-llama/llama_index) | 37087 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/85702467?s=40&v=4" width="20" height="20" alt=""> [langflow-ai](https://github.com/langflow-ai) / [langflow](https://github.com/langflow-ai/langflow) | 36690 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/31035808?s=40&v=4" width="20" height="20" alt=""> [mindsdb](https://github.com/mindsdb) / [mindsdb](https://github.com/mindsdb/mindsdb) | 26871 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/60330232?s=40&v=4" width="20" height="20" alt=""> [PostHog](https://github.com/PostHog) / [posthog](https://github.com/PostHog/posthog) | 22348 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/121462774?s=40&v=4" width="20" height="20" alt=""> [BerriAI](https://github.com/BerriAI) / [litellm](https://github.com/BerriAI/litellm) | 14508 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/78410652?s=40&v=4" width="20" height="20" alt=""> [GreyDGL](https://github.com/GreyDGL) / [PentestGPT](https://github.com/GreyDGL/PentestGPT) | 7319 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/152537519?s=40&v=4" width="20" height="20" alt=""> [superagent-ai](https://github.com/superagent-ai) / [superagent](https://github.com/superagent-ai/superagent) | 5352 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/7250217?s=40&v=4" width="20" height="20" alt=""> [Canner](https://github.com/Canner) / [WrenAI](https://github.com/Canner/WrenAI) | 2084 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/11855343?s=40&v=4" width="20" height="20" alt=""> [pingcap](https://github.com/pingcap) / [autoflow](https://github.com/pingcap/autoflow) | 1936 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/85268109?s=40&v=4" width="20" height="20" alt=""> [MLSysOps](https://github.com/MLSysOps) / [MLE-agent](https://github.com/MLSysOps/MLE-agent) | 1140 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/158137808?s=40&v=4" width="20" height="20" alt=""> [open-webui](https://github.com/open-webui) / [pipelines](https://github.com/open-webui/pipelines) | 1044 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/125468716?s=40&v=4" width="20" height="20" alt=""> [topoteretes](https://github.com/topoteretes) / [cognee](https://github.com/topoteretes/cognee) | 945 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/188657705?s=40&v=4" width="20" height="20" alt=""> [bRAGAI](https://github.com/bRAGAI) / [bRAG-langchain](https://github.com/bRAGAI/bRAG-langchain) | 770 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/169500408?s=40&v=4" width="20" height="20" alt=""> [opslane](https://github.com/opslane) / [opslane](https://github.com/opslane/opslane) | 675 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/151867818?s=40&v=4" width="20" height="20" alt=""> [dynamiq-ai](https://github.com/dynamiq-ai) / [dynamiq](https://github.com/dynamiq-ai/dynamiq) | 583 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/48585267?s=40&v=4" width="20" height="20" alt=""> [theopenconversationkit](https://github.com/theopenconversationkit) / [tock](https://github.com/theopenconversationkit/tock) | 505 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/20493493?s=40&v=4" width="20" height="20" alt=""> [andysingal](https://github.com/andysingal) / [llm-course](https://github.com/andysingal/llm-course) | 383 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/132396805?s=40&v=4" width="20" height="20" alt=""> [phospho-app](https://github.com/phospho-app) / [phospho](https://github.com/phospho-app/phospho) | 379 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/178644984?s=40&v=4" width="20" height="20" alt=""> [sentient-engineering](https://github.com/sentient-engineering) / [agent-q](https://github.com/sentient-engineering/agent-q) | 357 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/127165244?s=40&v=4" width="20" height="20" alt=""> [langgenius](https://github.com/langgenius) / [dify](https://github.com/langgenius/dify) | 54865 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/158137808?s=40&v=4" width="20" height="20" alt=""> [open-webui](https://github.com/open-webui) / [open-webui](https://github.com/open-webui/open-webui) | 51531 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/131470832?s=40&v=4" width="20" height="20" alt=""> [lobehub](https://github.com/lobehub) / [lobe-chat](https://github.com/lobehub/lobe-chat) | 49003 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/85702467?s=40&v=4" width="20" height="20" alt=""> [langflow-ai](https://github.com/langflow-ai) / [langflow](https://github.com/langflow-ai/langflow) | 39093 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/130722866?s=40&v=4" width="20" height="20" alt=""> [run-llama](https://github.com/run-llama) / [llama_index](https://github.com/run-llama/llama_index) | 37368 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/139558948?s=40&v=4" width="20" height="20" alt=""> [chatchat-space](https://github.com/chatchat-space) / [Langchain-Chatchat](https://github.com/chatchat-space/Langchain-Chatchat) | 32486 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/128289781?s=40&v=4" width="20" height="20" alt=""> [FlowiseAI](https://github.com/FlowiseAI) / [Flowise](https://github.com/FlowiseAI/Flowise) | 32448 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/31035808?s=40&v=4" width="20" height="20" alt=""> [mindsdb](https://github.com/mindsdb) / [mindsdb](https://github.com/mindsdb/mindsdb) | 26931 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/119600397?s=40&v=4" width="20" height="20" alt=""> [twentyhq](https://github.com/twentyhq) / [twenty](https://github.com/twentyhq/twenty) | 24195 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/60330232?s=40&v=4" width="20" height="20" alt=""> [PostHog](https://github.com/PostHog) / [posthog](https://github.com/PostHog/posthog) | 22618 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/121462774?s=40&v=4" width="20" height="20" alt=""> [BerriAI](https://github.com/BerriAI) / [litellm](https://github.com/BerriAI/litellm) | 15151 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/179202840?s=40&v=4" width="20" height="20" alt=""> [mediar-ai](https://github.com/mediar-ai) / [screenpipe](https://github.com/mediar-ai/screenpipe) | 11037 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/105877416?s=40&v=4" width="20" height="20" alt=""> [formbricks](https://github.com/formbricks) / [formbricks](https://github.com/formbricks/formbricks) | 9386 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/76263028?s=40&v=4" width="20" height="20" alt=""> [anthropics](https://github.com/anthropics) / [courses](https://github.com/anthropics/courses) | 8385 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/78410652?s=40&v=4" width="20" height="20" alt=""> [GreyDGL](https://github.com/GreyDGL) / [PentestGPT](https://github.com/GreyDGL/PentestGPT) | 7374 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/152537519?s=40&v=4" width="20" height="20" alt=""> [superagent-ai](https://github.com/superagent-ai) / [superagent](https://github.com/superagent-ai/superagent) | 5391 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/137907881?s=40&v=4" width="20" height="20" alt=""> [promptfoo](https://github.com/promptfoo) / [promptfoo](https://github.com/promptfoo/promptfoo) | 4976 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/157326433?s=40&v=4" width="20" height="20" alt=""> [onlook-dev](https://github.com/onlook-dev) / [onlook](https://github.com/onlook-dev/onlook) | 4141 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/7250217?s=40&v=4" width="20" height="20" alt=""> [Canner](https://github.com/Canner) / [WrenAI](https://github.com/Canner/WrenAI) | 2526 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/11855343?s=40&v=4" width="20" height="20" alt=""> [pingcap](https://github.com/pingcap) / [autoflow](https://github.com/pingcap/autoflow) | 2061 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/85268109?s=40&v=4" width="20" height="20" alt=""> [MLSysOps](https://github.com/MLSysOps) / [MLE-agent](https://github.com/MLSysOps/MLE-agent) | 1161 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/158137808?s=40&v=4" width="20" height="20" alt=""> [open-webui](https://github.com/open-webui) / [pipelines](https://github.com/open-webui/pipelines) | 1100 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/18422723?s=40&v=4" width="20" height="20" alt=""> [alishobeiri](https://github.com/alishobeiri) / [thread](https://github.com/alishobeiri/thread) | 1074 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/125468716?s=40&v=4" width="20" height="20" alt=""> [topoteretes](https://github.com/topoteretes) / [cognee](https://github.com/topoteretes/cognee) | 971 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/188657705?s=40&v=4" width="20" height="20" alt=""> [bRAGAI](https://github.com/bRAGAI) / [bRAG-langchain](https://github.com/bRAGAI/bRAG-langchain) | 823 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/169500408?s=40&v=4" width="20" height="20" alt=""> [opslane](https://github.com/opslane) / [opslane](https://github.com/opslane/opslane) | 677 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/151867818?s=40&v=4" width="20" height="20" alt=""> [dynamiq-ai](https://github.com/dynamiq-ai) / [dynamiq](https://github.com/dynamiq-ai/dynamiq) | 639 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/48585267?s=40&v=4" width="20" height="20" alt=""> [theopenconversationkit](https://github.com/theopenconversationkit) / [tock](https://github.com/theopenconversationkit/tock) | 514 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/20493493?s=40&v=4" width="20" height="20" alt=""> [andysingal](https://github.com/andysingal) / [llm-course](https://github.com/andysingal/llm-course) | 394 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/132396805?s=40&v=4" width="20" height="20" alt=""> [phospho-app](https://github.com/phospho-app) / [phospho](https://github.com/phospho-app/phospho) | 384 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/178644984?s=40&v=4" width="20" height="20" alt=""> [sentient-engineering](https://github.com/sentient-engineering) / [agent-q](https://github.com/sentient-engineering/agent-q) | 370 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/168552753?s=40&v=4" width="20" height="20" alt=""> [sql-agi](https://github.com/sql-agi) / [DB-GPT](https://github.com/sql-agi/DB-GPT) | 324 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/60330232?s=40&v=4" width="20" height="20" alt=""> [PostHog](https://github.com/PostHog) / [posthog-foss](https://github.com/PostHog/posthog-foss) | 300 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/609489?s=40&v=4" width="20" height="20" alt=""> [aorwall](https://github.com/aorwall) / [moatless-tools](https://github.com/aorwall/moatless-tools) | 278 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/185116535?s=40&v=4" width="20" height="20" alt=""> [block](https://github.com/block) / [goose](https://github.com/block/goose) | 237 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/66303003?s=40&v=4" width="20" height="20" alt=""> [RobotecAI](https://github.com/RobotecAI) / [rai](https://github.com/RobotecAI/rai) | 164 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/171735272?s=40&v=4" width="20" height="20" alt=""> [8090-inc](https://github.com/8090-inc) / [xrx-sample-apps](https://github.com/8090-inc/xrx-sample-apps) | 133 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/51827949?s=40&v=4" width="20" height="20" alt=""> [deepset-ai](https://github.com/deepset-ai) / [haystack-core-integrations](https://github.com/deepset-ai/haystack-core-integrations) | 122 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/134601687?s=40&v=4" width="20" height="20" alt=""> [langfuse](https://github.com/langfuse) / [langfuse-python](https://github.com/langfuse/langfuse-python) | 108 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/42655961?s=40&v=4" width="20" height="20" alt=""> [zozoheir](https://github.com/zozoheir) / [tinyllm](https://github.com/zozoheir/tinyllm) | 95 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/60330232?s=40&v=4" width="20" height="20" alt=""> [PostHog](https://github.com/PostHog) / [posthog-foss](https://github.com/PostHog/posthog-foss) | 305 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/154247157?s=40&v=4" width="20" height="20" alt=""> [vespperhq](https://github.com/vespperhq) / [vespper](https://github.com/vespperhq/vespper) | 304 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/185116535?s=40&v=4" width="20" height="20" alt=""> [block](https://github.com/block) / [goose](https://github.com/block/goose) | 295 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/609489?s=40&v=4" width="20" height="20" alt=""> [aorwall](https://github.com/aorwall) / [moatless-tools](https://github.com/aorwall/moatless-tools) | 291 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/2357342?s=40&v=4" width="20" height="20" alt=""> [dmayboroda](https://github.com/dmayboroda) / [minima](https://github.com/dmayboroda/minima) | 221 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/66303003?s=40&v=4" width="20" height="20" alt=""> [RobotecAI](https://github.com/RobotecAI) / [rai](https://github.com/RobotecAI/rai) | 172 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/148684274?s=40&v=4" width="20" height="20" alt=""> [i-am-alice](https://github.com/i-am-alice) / [3rd-devs](https://github.com/i-am-alice/3rd-devs) | 148 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/171735272?s=40&v=4" width="20" height="20" alt=""> [8090-inc](https://github.com/8090-inc) / [xrx-sample-apps](https://github.com/8090-inc/xrx-sample-apps) | 138 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/104478511?s=40&v=4" width="20" height="20" alt=""> [babelcloud](https://github.com/babelcloud) / [LLM-RGB](https://github.com/babelcloud/LLM-RGB) | 135 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/15125613?s=40&v=4" width="20" height="20" alt=""> [souzatharsis](https://github.com/souzatharsis) / [tamingLLMs](https://github.com/souzatharsis/tamingLLMs) | 129 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/169401942?s=40&v=4" width="20" height="20" alt=""> [LibreChat-AI](https://github.com/LibreChat-AI) / [librechat.ai](https://github.com/LibreChat-AI/librechat.ai) | 128 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/51827949?s=40&v=4" width="20" height="20" alt=""> [deepset-ai](https://github.com/deepset-ai) / [haystack-core-integrations](https://github.com/deepset-ai/haystack-core-integrations) | 126 |
|
||||
|
||||
@@ -155,4 +155,3 @@ volumes:
|
||||
driver: local
|
||||
langfuse_minio_data:
|
||||
driver: local
|
||||
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@
|
||||
"@langfuse/shared": "workspace:*",
|
||||
"@opentelemetry/api": ">=1.0.0 <1.10.0",
|
||||
"axios": "^1.7.7",
|
||||
"next": "^14.2.15",
|
||||
"next": "^14.2.21",
|
||||
"next-auth": "^4.24.11",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ service:
|
||||
Batched ingestion for Langfuse Tracing. If you want to use tracing via the API, such as to build your own Langfuse client implementation, this is the only API route you need to implement.
|
||||
|
||||
Notes:
|
||||
- Introduction to data model: https://langfuse.com/docs/tracing-data-model
|
||||
- Batch sizes are limited to 3.5 MB in total. You need to adjust the number of events per batch accordingly.
|
||||
- The API does not return a 4xx status code for input errors. Instead, it responds with a 207 status code, which includes a list of the encountered errors.
|
||||
method: POST
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse",
|
||||
"version": "3.2.0",
|
||||
"version": "3.5.2",
|
||||
"author": "engineering@langfuse.com",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
|
||||
@@ -66,6 +66,10 @@ const EnvSchema = z.object({
|
||||
.default("false"),
|
||||
LANGFUSE_USE_AZURE_BLOB: z.enum(["true", "false"]).default("false"),
|
||||
STRIPE_SECRET_KEY: z.string().optional(),
|
||||
|
||||
LANGFUSE_S3_CORE_DATA_EXPORT_IS_ENABLED: z
|
||||
.enum(["true", "false"])
|
||||
.default("false"),
|
||||
});
|
||||
|
||||
export const env: z.infer<typeof EnvSchema> =
|
||||
|
||||
@@ -112,6 +112,14 @@ export const observationsTableCols: ColumnDefinition[] = [
|
||||
options: [], // to be added at runtime
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Model ID",
|
||||
id: "modelId",
|
||||
type: "stringOptions",
|
||||
internal: 'o."internal_model_id"',
|
||||
options: [], // to be added at runtime
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Input Tokens",
|
||||
id: "inputTokens",
|
||||
@@ -186,6 +194,7 @@ export const observationsTableCols: ColumnDefinition[] = [
|
||||
// allows for undefined options, to offer filters while options are still loading
|
||||
export type ObservationOptions = {
|
||||
model: Array<OptionsDefinition>;
|
||||
modelId: Array<OptionsDefinition>;
|
||||
name: Array<OptionsDefinition>;
|
||||
traceName: Array<OptionsDefinition>;
|
||||
scores_avg: Array<string>;
|
||||
@@ -194,12 +203,15 @@ export type ObservationOptions = {
|
||||
};
|
||||
|
||||
export function observationsTableColsWithOptions(
|
||||
options?: ObservationOptions
|
||||
options?: ObservationOptions,
|
||||
): ColumnDefinition[] {
|
||||
return observationsTableCols.map((col) => {
|
||||
if (col.id === "model") {
|
||||
return { ...col, options: options?.model ?? [] };
|
||||
}
|
||||
if (col.id === "modelId") {
|
||||
return { ...col, options: options?.modelId ?? [] };
|
||||
}
|
||||
if (col.id === "name") {
|
||||
return { ...col, options: options?.name ?? [] };
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export * from "./redis/legacyIngestion";
|
||||
export * from "./redis/ingestionQueue";
|
||||
export * from "./redis/postHogIntegrationQueue";
|
||||
export * from "./redis/postHogIntegrationProcessingQueue";
|
||||
export * from "./redis/coreDataS3ExportQueue";
|
||||
export * from "./redis/experimentCreateQueue";
|
||||
export * from "./auth/types";
|
||||
export * from "./ingestion/legacy/index";
|
||||
|
||||
@@ -394,7 +394,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
model?: Model,
|
||||
existingObservation?: Omit<Observation, "input" | "output">,
|
||||
) {
|
||||
let newPromptTokens = body.usage?.input || body.usageDetails?.input;
|
||||
let newPromptTokens = body.usage?.input ?? body.usageDetails?.input;
|
||||
if (newPromptTokens === undefined && model && model.tokenizerId) {
|
||||
if (body.input) {
|
||||
newPromptTokens = calculateTokenDelegate({
|
||||
@@ -419,7 +419,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
let newCompletionTokens = body.usage?.output || body.usageDetails?.output;
|
||||
let newCompletionTokens = body.usage?.output ?? body.usageDetails?.output;
|
||||
|
||||
if (newCompletionTokens === undefined && model && model.tokenizerId) {
|
||||
if (body.output) {
|
||||
|
||||
@@ -112,11 +112,13 @@ export enum QueueName {
|
||||
DatasetRunItemUpsert = "dataset-run-item-upsert-queue",
|
||||
BatchExport = "batch-export-queue",
|
||||
IngestionQueue = "ingestion-queue", // Process single events with S3-merge
|
||||
IngestionSecondaryQueue = "secondary-ingestion-queue", // Separates high priority + high throughput projects from other projects.
|
||||
LegacyIngestionQueue = "legacy-ingestion-queue", // Used for batch processing of Ingestion
|
||||
CloudUsageMeteringQueue = "cloud-usage-metering-queue",
|
||||
ExperimentCreate = "experiment-create-queue",
|
||||
PostHogIntegrationQueue = "posthog-integration-queue",
|
||||
PostHogIntegrationProcessingQueue = "posthog-integration-processing-queue",
|
||||
CoreDataS3ExportQueue = "core-data-s3-export-queue",
|
||||
}
|
||||
|
||||
export enum QueueJobs {
|
||||
@@ -129,9 +131,11 @@ export enum QueueJobs {
|
||||
LegacyIngestionJob = "legacy-ingestion-job",
|
||||
CloudUsageMeteringJob = "cloud-usage-metering-job",
|
||||
IngestionJob = "ingestion-job",
|
||||
IngestionSecondaryJob = "secondary-ingestion-job",
|
||||
ExperimentCreateJob = "experiment-create-job",
|
||||
PostHogIntegrationJob = "posthog-integration-job",
|
||||
PostHogIntegrationProcessingJob = "posthog-integration-processing-job",
|
||||
CoreDataS3ExportJob = "core-data-s3-export-job",
|
||||
}
|
||||
|
||||
export type TQueueJobTypes = {
|
||||
@@ -183,6 +187,12 @@ export type TQueueJobTypes = {
|
||||
payload: IngestionEventQueueType;
|
||||
name: QueueJobs.IngestionJob;
|
||||
};
|
||||
[QueueName.IngestionSecondaryQueue]: {
|
||||
timestamp: Date;
|
||||
id: string;
|
||||
payload: IngestionEventQueueType;
|
||||
name: QueueJobs.IngestionJob;
|
||||
};
|
||||
[QueueName.ExperimentCreate]: {
|
||||
timestamp: Date;
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Queue } from "bullmq";
|
||||
import { QueueName, QueueJobs } from "../queues";
|
||||
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
|
||||
import { logger } from "../logger";
|
||||
import { env } from "../../env";
|
||||
|
||||
export class CoreDataS3ExportQueue {
|
||||
private static instance: Queue | null = null;
|
||||
|
||||
public static getInstance(): Queue | null {
|
||||
if (env.LANGFUSE_S3_CORE_DATA_EXPORT_IS_ENABLED !== "true") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (CoreDataS3ExportQueue.instance) {
|
||||
return CoreDataS3ExportQueue.instance;
|
||||
}
|
||||
|
||||
const newRedis = createNewRedisInstance({
|
||||
enableOfflineQueue: false,
|
||||
...redisQueueRetryOptions,
|
||||
});
|
||||
|
||||
CoreDataS3ExportQueue.instance = newRedis
|
||||
? new Queue(QueueName.CoreDataS3ExportQueue, {
|
||||
connection: newRedis,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 100,
|
||||
attempts: 5,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 5000,
|
||||
},
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
CoreDataS3ExportQueue.instance?.on("error", (err) => {
|
||||
logger.error("CoreDataS3ExportQueue error", err);
|
||||
});
|
||||
|
||||
if (CoreDataS3ExportQueue.instance) {
|
||||
logger.debug("Scheduling jobs for CoreDataS3ExportQueue");
|
||||
CoreDataS3ExportQueue.instance
|
||||
.add(
|
||||
QueueJobs.CoreDataS3ExportJob,
|
||||
{},
|
||||
{
|
||||
repeat: { pattern: "30 4 * * *" }, // every day at 4:30am
|
||||
},
|
||||
)
|
||||
.catch((err) => {
|
||||
logger.error("Error adding CoreDataS3ExportJob schedule", err);
|
||||
});
|
||||
}
|
||||
|
||||
return CoreDataS3ExportQueue.instance;
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,14 @@ import { CloudUsageMeteringQueue } from "./cloudUsageMeteringQueue";
|
||||
import { DatasetRunItemUpsertQueue } from "./datasetRunItemUpsert";
|
||||
import { EvalExecutionQueue } from "./evalExecutionQueue";
|
||||
import { ExperimentCreateQueue } from "./experimentCreateQueue";
|
||||
import { IngestionQueue } from "./ingestionQueue";
|
||||
import { IngestionQueue, SecondaryIngestionQueue } from "./ingestionQueue";
|
||||
import { LegacyIngestionQueue } from "./legacyIngestion";
|
||||
import { TraceUpsertQueue } from "./traceUpsert";
|
||||
import { TraceDeleteQueue } from "./traceDelete";
|
||||
import { ProjectDeleteQueue } from "./projectDelete";
|
||||
import { PostHogIntegrationQueue } from "./postHogIntegrationQueue";
|
||||
import { PostHogIntegrationProcessingQueue } from "./postHogIntegrationProcessingQueue";
|
||||
import { CoreDataS3ExportQueue } from "./coreDataS3ExportQueue";
|
||||
|
||||
export function getQueue(queueName: QueueName): Queue | null {
|
||||
switch (queueName) {
|
||||
@@ -39,6 +40,10 @@ export function getQueue(queueName: QueueName): Queue | null {
|
||||
return PostHogIntegrationQueue.getInstance();
|
||||
case QueueName.PostHogIntegrationProcessingQueue:
|
||||
return PostHogIntegrationProcessingQueue.getInstance();
|
||||
case QueueName.IngestionSecondaryQueue:
|
||||
return SecondaryIngestionQueue.getInstance();
|
||||
case QueueName.CoreDataS3ExportQueue:
|
||||
return CoreDataS3ExportQueue.getInstance();
|
||||
default:
|
||||
const exhaustiveCheckDefault: never = queueName;
|
||||
throw new Error(`Queue ${queueName} not found`);
|
||||
|
||||
@@ -43,3 +43,45 @@ export class IngestionQueue {
|
||||
return IngestionQueue.instance;
|
||||
}
|
||||
}
|
||||
|
||||
export class SecondaryIngestionQueue {
|
||||
private static instance: Queue<
|
||||
TQueueJobTypes[QueueName.IngestionSecondaryQueue]
|
||||
> | null = null;
|
||||
|
||||
public static getInstance(): Queue<
|
||||
TQueueJobTypes[QueueName.IngestionSecondaryQueue]
|
||||
> | null {
|
||||
if (SecondaryIngestionQueue.instance)
|
||||
return SecondaryIngestionQueue.instance;
|
||||
|
||||
const newRedis = createNewRedisInstance({
|
||||
enableOfflineQueue: false,
|
||||
...redisQueueRetryOptions,
|
||||
});
|
||||
|
||||
SecondaryIngestionQueue.instance = newRedis
|
||||
? new Queue<TQueueJobTypes[QueueName.IngestionSecondaryQueue]>(
|
||||
QueueName.IngestionSecondaryQueue,
|
||||
{
|
||||
connection: newRedis,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 100_000,
|
||||
attempts: 5,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 5000,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
: null;
|
||||
|
||||
SecondaryIngestionQueue.instance?.on("error", (err) => {
|
||||
logger.error("SecondaryIngestionQueue error", err);
|
||||
});
|
||||
|
||||
return SecondaryIngestionQueue.instance;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -536,6 +536,9 @@ const getObservationsTableInternal = async <T>(
|
||||
o.updated_at as "updated_at",
|
||||
o.provided_model_name as "provided_model_name",
|
||||
o.total_cost as "total_cost",
|
||||
o.prompt_id as "prompt_id",
|
||||
o.prompt_name as "prompt_name",
|
||||
o.prompt_version as "prompt_version",
|
||||
internal_model_id as "internal_model_id",
|
||||
if(isNull(end_time), NULL, date_diff('milliseconds', start_time, end_time)) as latency,
|
||||
if(isNull(completion_start_time), NULL, date_diff('milliseconds', start_time, completion_start_time)) as "time_to_first_token"`;
|
||||
@@ -747,6 +750,50 @@ export const getObservationsGroupedByModel = async (
|
||||
return res.map((r) => ({ model: r.name }));
|
||||
};
|
||||
|
||||
export const getObservationsGroupedByModelId = async (
|
||||
projectId: string,
|
||||
filter: FilterState,
|
||||
) => {
|
||||
const observationsFilter = new FilterList([
|
||||
new StringFilter({
|
||||
clickhouseTable: "observations",
|
||||
field: "project_id",
|
||||
operator: "=",
|
||||
value: projectId,
|
||||
tablePrefix: "o",
|
||||
}),
|
||||
]);
|
||||
|
||||
observationsFilter.push(
|
||||
...createFilterFromFilterState(
|
||||
filter,
|
||||
observationsTableUiColumnDefinitions,
|
||||
),
|
||||
);
|
||||
|
||||
const appliedObservationsFilter = observationsFilter.apply();
|
||||
|
||||
// We mainly use queries like this to retrieve filter options.
|
||||
// Therefore, we can skip final as some inaccuracy in count is acceptable.
|
||||
const query = `
|
||||
SELECT o.internal_model_id as modelId
|
||||
FROM observations o
|
||||
WHERE ${appliedObservationsFilter.query}
|
||||
AND o.type = 'GENERATION'
|
||||
GROUP BY o.internal_model_id
|
||||
ORDER BY count() DESC
|
||||
LIMIT 1000;
|
||||
`;
|
||||
|
||||
const res = await queryClickhouse<{ modelId: string }>({
|
||||
query,
|
||||
params: {
|
||||
...appliedObservationsFilter.params,
|
||||
},
|
||||
});
|
||||
return res.map((r) => ({ modelId: r.modelId }));
|
||||
};
|
||||
|
||||
export const getObservationsGroupedByName = async (
|
||||
projectId: string,
|
||||
filter: FilterState,
|
||||
|
||||
@@ -126,10 +126,11 @@ export const convertObservation = (
|
||||
parseClickhouseUTCDateTimeFormat(record.start_time).getTime()
|
||||
: null,
|
||||
timeToFirstToken: record.completion_start_time
|
||||
? parseClickhouseUTCDateTimeFormat(
|
||||
? (parseClickhouseUTCDateTimeFormat(
|
||||
record.completion_start_time,
|
||||
).getTime() -
|
||||
parseClickhouseUTCDateTimeFormat(record.start_time).getTime()
|
||||
parseClickhouseUTCDateTimeFormat(record.start_time).getTime()) /
|
||||
1000
|
||||
: null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -680,10 +680,25 @@ export const getTotalUserCount = async (
|
||||
});
|
||||
};
|
||||
|
||||
export const getUserMetrics = async (projectId: string, userIds: string[]) => {
|
||||
export const getUserMetrics = async (
|
||||
projectId: string,
|
||||
userIds: string[],
|
||||
filter: FilterState,
|
||||
) => {
|
||||
if (userIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// filter state contains date range filter for traces so far.
|
||||
const chFilter = new FilterList(
|
||||
createFilterFromFilterState(filter, tracesTableUiColumnDefinitions),
|
||||
);
|
||||
const chFilterRes = chFilter.apply();
|
||||
|
||||
const timestampFilter = chFilter.find(
|
||||
(f) => f.field === "timestamp" && f.operator === ">=",
|
||||
);
|
||||
|
||||
// this query uses window functions on observations + traces to always get only the first row and thereby remove deduplicates
|
||||
// we filter wherever possible by project id and user id
|
||||
const query = `
|
||||
@@ -713,6 +728,7 @@ export const getUserMetrics = async (projectId: string, userIds: string[]) => {
|
||||
observations o
|
||||
WHERE
|
||||
o.project_id = {projectId: String }
|
||||
${timestampFilter ? `AND o.start_time >= {traceTimestamp: DateTime64(3)} - ${OBSERVATIONS_TO_TRACE_INTERVAL}` : ""}
|
||||
AND o.trace_id in (
|
||||
SELECT
|
||||
distinct id
|
||||
@@ -721,6 +737,7 @@ export const getUserMetrics = async (projectId: string, userIds: string[]) => {
|
||||
where
|
||||
user_id IN ({userIds: Array(String) })
|
||||
AND project_id = {projectId: String }
|
||||
${filter.length > 0 ? `AND ${chFilterRes.query}` : ""}
|
||||
)
|
||||
AND o.type = 'GENERATION'
|
||||
) as o
|
||||
@@ -740,6 +757,7 @@ export const getUserMetrics = async (projectId: string, userIds: string[]) => {
|
||||
WHERE
|
||||
t.user_id IN ({userIds: Array(String) })
|
||||
AND t.project_id = {projectId: String }
|
||||
${filter.length > 0 ? `AND ${chFilterRes.query}` : ""}
|
||||
) as t on t.id = o.trace_id
|
||||
and t.project_id = o.project_id
|
||||
WHERE
|
||||
@@ -778,8 +796,17 @@ export const getUserMetrics = async (projectId: string, userIds: string[]) => {
|
||||
params: {
|
||||
projectId,
|
||||
userIds,
|
||||
...chFilterRes.params,
|
||||
...(timestampFilter
|
||||
? {
|
||||
traceTimestamp: convertDateToClickhouseDateTime(
|
||||
(timestampFilter as DateTimeFilter).value,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
return rows.map((row) => ({
|
||||
userId: row.user_id,
|
||||
maxTimestamp: parseClickhouseUTCDateTimeFormat(row.max_timestamp),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Trace } from "@prisma/client";
|
||||
import { Prisma, Trace } from "@prisma/client";
|
||||
import { parseClickhouseUTCDateTimeFormat } from "./clickhouse";
|
||||
import { TraceRecordReadType } from "./definitions";
|
||||
import { convertDateToClickhouseDateTime } from "../clickhouse/client";
|
||||
import { parseJsonPrioritised } from "../../utils/json";
|
||||
import { jsonSchema } from "../../utils/zod";
|
||||
|
||||
export const convertTraceDomainToClickhouse = (
|
||||
trace: Trace,
|
||||
@@ -43,8 +45,12 @@ export const convertClickhouseToDomain = (
|
||||
userId: record.user_id ?? null,
|
||||
sessionId: record.session_id ?? null,
|
||||
public: record.public,
|
||||
input: record.input ?? null,
|
||||
output: record.output ?? null,
|
||||
input: (record.input
|
||||
? jsonSchema.parse(parseJsonPrioritised(record.input))
|
||||
: null) as Prisma.JsonValue | null,
|
||||
output: (record.output
|
||||
? jsonSchema.parse(parseJsonPrioritised(record.output))
|
||||
: null) as Prisma.JsonValue | null,
|
||||
metadata: record.metadata,
|
||||
createdAt: parseClickhouseUTCDateTimeFormat(record.created_at),
|
||||
updatedAt: parseClickhouseUTCDateTimeFormat(record.updated_at),
|
||||
|
||||
@@ -127,6 +127,12 @@ export const observationsTableUiColumnDefinitions: UiColumnMapping[] = [
|
||||
clickhouseTableName: "observations",
|
||||
clickhouseSelect: 'o."provided_model_name"',
|
||||
},
|
||||
{
|
||||
uiTableName: "Model ID",
|
||||
uiTableId: "modelId",
|
||||
clickhouseTableName: "observations",
|
||||
clickhouseSelect: 'o."internal_model_id"',
|
||||
},
|
||||
{
|
||||
uiTableName: "Input Tokens",
|
||||
uiTableId: "inputTokens",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { UiColumnMapping } from ".";
|
||||
|
||||
export const sessionCols: UiColumnMapping[] = [
|
||||
// we do not access the traces scores in clichouse. We default back to the trace timestamps.
|
||||
// we do not access the traces scores in ClickHouse. We default back to the trace timestamps.
|
||||
|
||||
{
|
||||
uiTableName: "⭐️",
|
||||
@@ -26,6 +26,8 @@ export const sessionCols: UiColumnMapping[] = [
|
||||
uiTableId: "sessionDuration",
|
||||
clickhouseTableName: "traces",
|
||||
clickhouseSelect: "duration",
|
||||
// If we use the default of Decimal64(12), we cannot filter for more than ~40min due to an overflow
|
||||
clickhouseTypeOverwrite: "Decimal64(3)",
|
||||
},
|
||||
{
|
||||
uiTableName: "Count Traces",
|
||||
@@ -86,6 +88,8 @@ export const sessionCols: UiColumnMapping[] = [
|
||||
uiTableId: "sessionDuration",
|
||||
clickhouseTableName: "traces",
|
||||
clickhouseSelect: "duration",
|
||||
// If we use the default of Decimal64(12), we cannot filter for more than ~40min due to an overflow
|
||||
clickhouseTypeOverwrite: "Decimal64(3)",
|
||||
},
|
||||
{
|
||||
uiTableName: "Traces Count",
|
||||
|
||||
Generated
+329
-442
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "3.2.0",
|
||||
"version": "3.5.2",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -117,7 +117,7 @@
|
||||
"langchain": "^0.3.6",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.462.0",
|
||||
"next": "^14.2.15",
|
||||
"next": "^14.2.21",
|
||||
"next-auth": "^4.24.11",
|
||||
"next-query-params": "^5.0.1",
|
||||
"next-themes": "^0.3.0",
|
||||
@@ -178,7 +178,7 @@
|
||||
"postcss": "^8.4.47",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.6",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.19.1",
|
||||
|
||||
@@ -723,6 +723,9 @@ paths:
|
||||
Notes:
|
||||
|
||||
|
||||
- Introduction to data model:
|
||||
https://langfuse.com/docs/tracing-data-model
|
||||
|
||||
- Batch sizes are limited to 3.5 MB in total. You need to adjust the
|
||||
number of events per batch accordingly.
|
||||
|
||||
|
||||
@@ -558,7 +558,7 @@
|
||||
"_type": "endpoint",
|
||||
"name": "Batch",
|
||||
"request": {
|
||||
"description": "Batched ingestion for Langfuse Tracing. If you want to use tracing via the API, such as to build your own Langfuse client implementation, this is the only API route you need to implement.\n\nNotes:\n\n- Batch sizes are limited to 3.5 MB in total. You need to adjust the number of events per batch accordingly.\n- The API does not return a 4xx status code for input errors. Instead, it responds with a 207 status code, which includes a list of the encountered errors.",
|
||||
"description": "Batched ingestion for Langfuse Tracing. If you want to use tracing via the API, such as to build your own Langfuse client implementation, this is the only API route you need to implement.\n\nNotes:\n\n- Introduction to data model: https://langfuse.com/docs/tracing-data-model\n- Batch sizes are limited to 3.5 MB in total. You need to adjust the number of events per batch accordingly.\n- The API does not return a 4xx status code for input errors. Instead, it responds with a 207 status code, which includes a list of the encountered errors.",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/ingestion",
|
||||
"host": [
|
||||
|
||||
@@ -244,7 +244,7 @@ describe("Fetch datasets for UI presentation", () => {
|
||||
expect(JSON.stringify(secondRun.scores)).toEqual(JSON.stringify({}));
|
||||
});
|
||||
|
||||
it.only("should test that dataset runs can link to the same traces", async () => {
|
||||
it("should test that dataset runs can link to the same traces", async () => {
|
||||
const datasetId = v4();
|
||||
|
||||
await prisma.dataset.create({
|
||||
|
||||
@@ -215,6 +215,102 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
|
||||
expect(getDataset.body.items[0].id).toEqual("active-item-id");
|
||||
});
|
||||
|
||||
it("should correctly update dataset items", async () => {
|
||||
const datasetItemId = v4();
|
||||
const datasetName = v4();
|
||||
|
||||
await prisma.dataset.create({
|
||||
data: {
|
||||
name: datasetName,
|
||||
projectId: projectId,
|
||||
},
|
||||
});
|
||||
|
||||
await makeZodVerifiedAPICall(
|
||||
PostDatasetItemsV1Response,
|
||||
"POST",
|
||||
"/api/public/dataset-items",
|
||||
{
|
||||
datasetName: datasetName,
|
||||
id: datasetItemId,
|
||||
input: { key: "value" },
|
||||
expectedOutput: { key: "value" },
|
||||
metadata: null,
|
||||
sourceTraceId: null,
|
||||
sourceObservationId: null,
|
||||
status: null,
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
await makeZodVerifiedAPICall(
|
||||
PostDatasetItemsV1Response,
|
||||
"POST",
|
||||
"/api/public/dataset-items",
|
||||
{
|
||||
datasetName: datasetName,
|
||||
id: datasetItemId,
|
||||
input: { john: "doe" },
|
||||
expectedOutput: { john: "doe" },
|
||||
metadata: null,
|
||||
sourceTraceId: null,
|
||||
sourceObservationId: null,
|
||||
status: null,
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
const databaseDatasetItem = await prisma.datasetItem.findFirst({
|
||||
where: {
|
||||
id: datasetItemId,
|
||||
},
|
||||
});
|
||||
expect(databaseDatasetItem).toMatchObject({
|
||||
input: { john: "doe" },
|
||||
expectedOutput: { john: "doe" },
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 404 when trying to update dataset item that exists in different dataset of the same project", async () => {
|
||||
const datasetItemId = v4();
|
||||
|
||||
const dataset = await prisma.dataset.create({
|
||||
data: {
|
||||
name: "dataset-name-1",
|
||||
projectId: projectId,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.dataset.create({
|
||||
data: {
|
||||
name: "dataset-name-2",
|
||||
projectId: projectId,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.datasetItem.create({
|
||||
data: {
|
||||
id: datasetItemId,
|
||||
datasetId: dataset.id,
|
||||
projectId: projectId,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await makeAPICall(
|
||||
"POST",
|
||||
"/api/public/dataset-items",
|
||||
{
|
||||
datasetName: "dataset-name-2",
|
||||
id: datasetItemId,
|
||||
input: { key: "new-value" },
|
||||
expectedOutput: { key: "new-value" },
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it("GET datasets (v1 & v2)", async () => {
|
||||
// v1 post
|
||||
await makeZodVerifiedAPICall(
|
||||
|
||||
@@ -154,8 +154,9 @@ describe("Clickhouse Observations Repository Test", () => {
|
||||
expect(firstObservation.promptId).toEqual(observation.prompt_id);
|
||||
expect(firstObservation.endTime).toEqual(new Date(observation.end_time));
|
||||
expect(firstObservation.timeToFirstToken).toEqual(
|
||||
new Date(observation.completion_start_time).getTime() -
|
||||
new Date(observation.start_time).getTime(),
|
||||
(new Date(observation.completion_start_time).getTime() -
|
||||
new Date(observation.start_time).getTime()) /
|
||||
1000,
|
||||
);
|
||||
expect(firstObservation.timeToFirstToken).toBeGreaterThan(0);
|
||||
expect(firstObservation.calculatedTotalCost).toEqual(
|
||||
|
||||
@@ -35,6 +35,14 @@ describe("Clickhouse Traces Repository Test", () => {
|
||||
release: null,
|
||||
version: null,
|
||||
user_id: null,
|
||||
input: JSON.stringify({
|
||||
this: {
|
||||
is: {
|
||||
a: ["complex", "object"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
output: "regular string",
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
event_ts: Date.now(),
|
||||
@@ -63,8 +71,8 @@ describe("Clickhouse Traces Repository Test", () => {
|
||||
expect(result.userId).toEqual(trace.user_id);
|
||||
expect(result.sessionId).toEqual(trace.session_id);
|
||||
expect(result.public).toEqual(trace.public);
|
||||
expect(result.input).toEqual(null);
|
||||
expect(result.output).toEqual(null);
|
||||
expect(result.input).toEqual(JSON.parse(trace.input));
|
||||
expect(result.output).toEqual("regular string");
|
||||
expect(result.metadata).toEqual(trace.metadata);
|
||||
expect(result.createdAt).toEqual(new Date(trace.created_at));
|
||||
expect(result.updatedAt).toEqual(new Date(trace.updated_at));
|
||||
|
||||
@@ -52,7 +52,7 @@ describe("getUserMetrics function", () => {
|
||||
|
||||
await createObservationsInClickhouse([observation1, observation2]);
|
||||
|
||||
const userMetrics = await getUserMetrics(projectId, [userId]);
|
||||
const userMetrics = await getUserMetrics(projectId, [userId], []);
|
||||
|
||||
expect(userMetrics.length).toBe(1);
|
||||
expect(userMetrics[0]).toMatchObject({
|
||||
|
||||
@@ -37,12 +37,17 @@ export const BatchExportTableButton: React.FC<BatchExportTableButtonProps> = (
|
||||
showSuccessToast({
|
||||
title: "Export queued",
|
||||
description: "You will receive an email when the export is ready.",
|
||||
duration: 10000,
|
||||
link: {
|
||||
href: `/project/${props.projectId}/settings/exports`,
|
||||
text: "View exports",
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
const hasAccess = useHasProjectAccess({
|
||||
projectId: props.projectId,
|
||||
scope: "batchExport:create",
|
||||
scope: "batchExports:create",
|
||||
});
|
||||
|
||||
const handleExport = async (format: BatchExportFileFormat) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/src/components/ui/select";
|
||||
@@ -69,6 +70,7 @@ export const ModelParameters: React.FC<
|
||||
value={modelParams.provider.value}
|
||||
options={availableProviders}
|
||||
updateModelParam={updateModelParamValue}
|
||||
evalModelsOnly={evalModelsOnly}
|
||||
/>
|
||||
<ModelParamsSelect
|
||||
title="Model name"
|
||||
@@ -147,6 +149,7 @@ type ModelParamsSelectProps = {
|
||||
updateModelParam: ModelParamsContext["updateModelParamValue"];
|
||||
disabled?: boolean;
|
||||
modelParamsDescription?: string;
|
||||
evalModelsOnly?: boolean;
|
||||
};
|
||||
const ModelParamsSelect = ({
|
||||
title,
|
||||
@@ -156,6 +159,7 @@ const ModelParamsSelect = ({
|
||||
updateModelParam,
|
||||
disabled,
|
||||
modelParamsDescription,
|
||||
evalModelsOnly,
|
||||
}: ModelParamsSelectProps) => {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -186,6 +190,8 @@ const ModelParamsSelect = ({
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectSeparator />
|
||||
<CreateLLMApiKeyDialog evalModelsOnly={evalModelsOnly} />
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{modelParamsDescription ? (
|
||||
|
||||
@@ -40,6 +40,12 @@ export type Route = {
|
||||
};
|
||||
|
||||
export const ROUTES: Route[] = [
|
||||
{
|
||||
title: "Organizations",
|
||||
pathname: "/",
|
||||
icon: Grid2X2,
|
||||
show: ({ organization }) => organization === undefined,
|
||||
},
|
||||
{
|
||||
title: "Projects",
|
||||
pathname: "/organization/[organizationId]",
|
||||
@@ -81,7 +87,6 @@ export const ROUTES: Route[] = [
|
||||
title: "Evaluation",
|
||||
icon: Lightbulb,
|
||||
pathname: `/project/[projectId]/annotation-queues`,
|
||||
label: "Beta",
|
||||
entitlements: ["annotation-queues", "model-based-evaluations"],
|
||||
projectRbacScopes: ["annotationQueues:read", "evalJob:read"],
|
||||
items: [
|
||||
|
||||
@@ -27,19 +27,18 @@ export const StatusBadge = ({
|
||||
let dotPingColor = "bg-muted-foreground";
|
||||
let showDot = isLive;
|
||||
|
||||
if (statusCategories.active.includes(type)) {
|
||||
if (statusCategories.active.includes(type.toLowerCase())) {
|
||||
badgeColor = "bg-light-green text-dark-green";
|
||||
dotColor = "animate-ping bg-dark-green";
|
||||
dotPingColor = "bg-dark-green";
|
||||
} else if (statusCategories.pending.includes(type)) {
|
||||
} else if (statusCategories.pending.includes(type.toLowerCase())) {
|
||||
badgeColor = "bg-light-yellow text-dark-yellow";
|
||||
dotColor = "animate-ping bg-dark-yellow";
|
||||
dotPingColor = "bg-dark-yellow";
|
||||
} else if (statusCategories.error.includes(type)) {
|
||||
} else if (statusCategories.error.includes(type.toLowerCase())) {
|
||||
badgeColor = "bg-light-red text-dark-red";
|
||||
dotColor = "animate-ping bg-dark-red";
|
||||
dotPingColor = "bg-dark-red";
|
||||
} else if (statusCategories.completed.includes(type)) {
|
||||
showDot = false;
|
||||
} else if (statusCategories.completed.includes(type.toLowerCase())) {
|
||||
badgeColor = "bg-light-green text-dark-green";
|
||||
showDot = false;
|
||||
}
|
||||
|
||||
@@ -69,11 +69,7 @@ export function NavMain({
|
||||
<SidebarGroup>
|
||||
<SidebarMenu>
|
||||
{showFeedbackButton && (
|
||||
<FeedbackButtonWrapper
|
||||
title="Provide feedback"
|
||||
description="What do you think about this project? What can be improved?"
|
||||
type="feedback"
|
||||
>
|
||||
<FeedbackButtonWrapper>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton tooltip="Feedback">
|
||||
<MessageSquarePlus className="h-5 w-5" aria-hidden="true" />
|
||||
|
||||
+19
-16
@@ -71,7 +71,8 @@ export function TraceTableMultiSelectAction({
|
||||
},
|
||||
});
|
||||
|
||||
const hasEntitlement = useHasEntitlement("annotation-queues");
|
||||
const hasAnnotationEntitlement = useHasEntitlement("annotation-queues");
|
||||
const hasTraceDeletionEntitlement = useHasEntitlement("trace-deletion");
|
||||
const hasAddToQueueAccess = useHasProjectAccess({
|
||||
projectId,
|
||||
scope: "annotationQueues:CUD",
|
||||
@@ -97,7 +98,7 @@ export function TraceTableMultiSelectAction({
|
||||
{
|
||||
projectId,
|
||||
},
|
||||
{ enabled: session.status === "authenticated" && hasEntitlement },
|
||||
{ enabled: session.status === "authenticated" && hasAnnotationEntitlement },
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -110,20 +111,22 @@ export function TraceTableMultiSelectAction({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
disabled={!hasDeleteAccess}
|
||||
onClick={() => {
|
||||
capture("trace:delete_form_open", {
|
||||
count: selectedTraceIds.length,
|
||||
source: "table-multi-select",
|
||||
});
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash className="mr-2 h-4 w-4" />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
{hasEntitlement && (
|
||||
{hasTraceDeletionEntitlement && (
|
||||
<DropdownMenuItem
|
||||
disabled={!hasDeleteAccess}
|
||||
onClick={() => {
|
||||
capture("trace:delete_form_open", {
|
||||
count: selectedTraceIds.length,
|
||||
source: "table-multi-select",
|
||||
});
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash className="mr-2 h-4 w-4" />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{hasAnnotationEntitlement && (
|
||||
<DropdownMenuItem
|
||||
disabled={!hasAddToQueueAccess}
|
||||
onClick={() => {
|
||||
|
||||
@@ -56,6 +56,7 @@ interface DataTableProps<TData, TValue> {
|
||||
paginationClassName?: string;
|
||||
isBorderless?: boolean;
|
||||
shouldRenderGroupHeaders?: boolean;
|
||||
onRowClick?: (row: TData) => void;
|
||||
}
|
||||
|
||||
export interface AsyncTableData<T> {
|
||||
@@ -108,6 +109,7 @@ export function DataTable<TData extends object, TValue>({
|
||||
paginationClassName,
|
||||
isBorderless = false,
|
||||
shouldRenderGroupHeaders = false,
|
||||
onRowClick,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const rowheighttw = getRowHeightTailwindClass(rowHeight);
|
||||
@@ -319,6 +321,7 @@ export function DataTable<TData extends object, TValue>({
|
||||
columns={columns}
|
||||
data={data}
|
||||
help={help}
|
||||
onRowClick={onRowClick}
|
||||
/>
|
||||
) : (
|
||||
<TableBodyComponent
|
||||
@@ -327,6 +330,7 @@ export function DataTable<TData extends object, TValue>({
|
||||
columns={columns}
|
||||
data={data}
|
||||
help={help}
|
||||
onRowClick={onRowClick}
|
||||
/>
|
||||
)}
|
||||
</Table>
|
||||
@@ -368,6 +372,7 @@ interface TableBodyComponentProps<TData> {
|
||||
columns: LangfuseColumnDef<TData, any>[];
|
||||
data: AsyncTableData<TData[]>;
|
||||
help?: { description: string; href: string };
|
||||
onRowClick?: (row: TData) => void;
|
||||
}
|
||||
|
||||
function TableBodyComponent<TData>({
|
||||
@@ -376,6 +381,7 @@ function TableBodyComponent<TData>({
|
||||
columns,
|
||||
data,
|
||||
help,
|
||||
onRowClick,
|
||||
}: TableBodyComponentProps<TData>) {
|
||||
return (
|
||||
<TableBody>
|
||||
@@ -390,7 +396,13 @@ function TableBodyComponent<TData>({
|
||||
</TableRow>
|
||||
) : table.getRowModel().rows.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableRow
|
||||
key={row.id}
|
||||
onClick={() => onRowClick?.(row.original)}
|
||||
className={
|
||||
onRowClick ? "cursor-pointer hover:bg-accent" : undefined
|
||||
}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
|
||||
@@ -42,7 +42,8 @@ import useColumnOrder from "@/src/features/column-visibility/hooks/useColumnOrde
|
||||
import { BatchExportTableButton } from "@/src/components/BatchExportTableButton";
|
||||
import { useClickhouse } from "@/src/components/layouts/ClickhouseAdminToggle";
|
||||
import { BreakdownTooltip } from "@/src/components/trace/BreakdownToolTip";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { InfoIcon, PlusCircle } from "lucide-react";
|
||||
import { UpsertModelFormDrawer } from "@/src/features/models/components/UpsertModelFormDrawer";
|
||||
|
||||
export type GenerationsTableRow = {
|
||||
id: string;
|
||||
@@ -83,6 +84,7 @@ export type GenerationsTableProps = {
|
||||
projectId: string;
|
||||
promptName?: string;
|
||||
promptVersion?: number;
|
||||
modelId?: string;
|
||||
omittedFilter?: string[];
|
||||
};
|
||||
|
||||
@@ -90,6 +92,7 @@ export default function GenerationsTable({
|
||||
projectId,
|
||||
promptName,
|
||||
promptVersion,
|
||||
modelId,
|
||||
omittedFilter = [],
|
||||
}: GenerationsTableProps) {
|
||||
const [searchQuery, setSearchQuery] = useQueryParam(
|
||||
@@ -143,6 +146,17 @@ export default function GenerationsTable({
|
||||
]
|
||||
: [];
|
||||
|
||||
const modelIdFilter: FilterState = modelId
|
||||
? [
|
||||
{
|
||||
column: "Model ID",
|
||||
type: "string",
|
||||
operator: "=",
|
||||
value: modelId,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const dateRangeFilter: FilterState = dateRange
|
||||
? [
|
||||
{
|
||||
@@ -158,6 +172,7 @@ export default function GenerationsTable({
|
||||
...dateRangeFilter,
|
||||
...promptNameFilter,
|
||||
...promptVersionFilter,
|
||||
...modelIdFilter,
|
||||
]);
|
||||
|
||||
const getCountPayload = {
|
||||
@@ -447,7 +462,56 @@ export default function GenerationsTable({
|
||||
size: 150,
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const model = row.getValue("model") as string;
|
||||
const modelId = row.getValue("modelId") as string | undefined;
|
||||
|
||||
if (!model) return null;
|
||||
|
||||
return modelId ? (
|
||||
<TableLink
|
||||
path={`/project/${projectId}/models/${modelId}`}
|
||||
value={model}
|
||||
/>
|
||||
) : (
|
||||
<UpsertModelFormDrawer
|
||||
action="create"
|
||||
projectId={projectId}
|
||||
prefilledModelData={{
|
||||
modelName: model,
|
||||
prices:
|
||||
Object.keys(row.original.usageDetails).length > 0
|
||||
? Object.keys(row.original.usageDetails)
|
||||
.filter((key) => key != "total")
|
||||
.reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = 0.000001;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
)
|
||||
: undefined,
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
<span>{model}</span>
|
||||
<PlusCircle className="h-3 w-3" />
|
||||
</span>
|
||||
</UpsertModelFormDrawer>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: "modelId",
|
||||
id: "modelId",
|
||||
header: "Model ID",
|
||||
size: 100,
|
||||
enableHiding: true,
|
||||
defaultHidden: true,
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: "inputTokens",
|
||||
id: "inputTokens",
|
||||
@@ -691,6 +755,7 @@ export default function GenerationsTable({
|
||||
name: generation.name ?? undefined,
|
||||
version: generation.version ?? "",
|
||||
model: generation.model ?? "",
|
||||
modelId: generation.modelId ?? undefined,
|
||||
level: generation.level,
|
||||
statusMessage: generation.statusMessage ?? undefined,
|
||||
usage: {
|
||||
|
||||
@@ -1,39 +1,40 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { DataTable } from "@/src/components/table/data-table";
|
||||
import { type LangfuseColumnDef } from "@/src/components/table/types";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/src/components/ui/popover";
|
||||
import { useState } from "react";
|
||||
import useColumnVisibility from "@/src/features/column-visibility/hooks/useColumnVisibility";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { usdFormatter } from "@/src/utils/numbers";
|
||||
import { type Prisma, type Model } from "@langfuse/shared/src/db";
|
||||
import Decimal from "decimal.js";
|
||||
import { Trash } from "lucide-react";
|
||||
import { type Prisma } from "@langfuse/shared/src/db";
|
||||
import { useQueryParams, withDefault, NumberParam } from "use-query-params";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { IOTableCell } from "@/src/components/ui/CodeJsonViewer";
|
||||
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
|
||||
import { DataTableToolbar } from "@/src/components/table/data-table-toolbar";
|
||||
import useColumnOrder from "@/src/features/column-visibility/hooks/useColumnOrder";
|
||||
import { type GetModelResult } from "@/src/features/models/validation";
|
||||
import { DeleteModelButton } from "@/src/features/models/components/DeleteModelButton";
|
||||
import { EditModelButton } from "@/src/features/models/components/EditModelButton";
|
||||
import { CloneModelButton } from "@/src/features/models/components/CloneModelButton";
|
||||
import { PriceBreakdownTooltip } from "@/src/features/models/components/PriceBreakdownTooltip";
|
||||
import { UserCircle2Icon } from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/src/components/ui/tooltip";
|
||||
import { LangfuseIcon } from "@/src/components/LangfuseLogo";
|
||||
import { useRouter } from "next/router";
|
||||
import { PriceUnitSelector } from "@/src/features/models/components/PriceUnitSelector";
|
||||
import { usePriceUnitMultiplier } from "@/src/features/models/hooks/usePriceUnitMultiplier";
|
||||
|
||||
export type ModelTableRow = {
|
||||
modelId: string;
|
||||
maintainer: string;
|
||||
modelName: string;
|
||||
matchPattern: string;
|
||||
startDate?: Date;
|
||||
inputPrice?: Decimal;
|
||||
outputPrice?: Decimal;
|
||||
totalPrice?: Decimal;
|
||||
unit: string;
|
||||
prices?: Record<string, number>;
|
||||
tokenizerId?: string;
|
||||
config?: Prisma.JsonValue;
|
||||
serverResponse: GetModelResult;
|
||||
};
|
||||
|
||||
const modelConfigDescriptions = {
|
||||
@@ -43,39 +44,46 @@ const modelConfigDescriptions = {
|
||||
"Regex pattern to match `model` parameter of generations to model pricing",
|
||||
startDate:
|
||||
"Date to start pricing model. If not set, model is active unless a more recent version exists.",
|
||||
inputPrice: "Price per 1000 units of input",
|
||||
outputPrice: "Price per 1000 units of output",
|
||||
totalPrice:
|
||||
"Price per 1000 units, for models that don't have input/output specific prices",
|
||||
unit: "Unit of measurement for generative model, can be TOKENS, CHARACTERS, SECONDS, MILLISECONDS, REQUESTS or IMAGES.",
|
||||
prices: "Prices per usage type",
|
||||
tokenizerId:
|
||||
"Tokenizer used for this model to calculate token counts if none are ingested. Pick from list of supported tokenizers.",
|
||||
config:
|
||||
"Some tokenizers require additional configuration (e.g. openai tiktoken). See docs for details.",
|
||||
maintainer:
|
||||
"Maintainer of the model. Langfuse managed models can be cloned, user managed models can be edited and deleted. To supersede a Langfuse managed model, set the custom model name to the Langfuse model name.",
|
||||
} as const;
|
||||
|
||||
export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
const router = useRouter();
|
||||
const [paginationState, setPaginationState] = useQueryParams({
|
||||
pageIndex: withDefault(NumberParam, 0),
|
||||
pageSize: withDefault(NumberParam, 50),
|
||||
});
|
||||
const models = api.models.all.useQuery({
|
||||
page: paginationState.pageIndex,
|
||||
limit: paginationState.pageSize,
|
||||
projectId,
|
||||
});
|
||||
const models = api.models.getAll.useQuery(
|
||||
{
|
||||
page: paginationState.pageIndex,
|
||||
limit: paginationState.pageSize,
|
||||
projectId,
|
||||
},
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: 1000 * 60 * 10,
|
||||
},
|
||||
);
|
||||
const totalCount = models.data?.totalCount ?? null;
|
||||
const { priceUnit } = usePriceUnitMultiplier();
|
||||
const [rowHeight, setRowHeight] = useRowHeightLocalStorage("models", "m");
|
||||
|
||||
const [rowHeight, setRowHeight] = useRowHeightLocalStorage("models", "s");
|
||||
// Set row height to medium if small as view is not optimized for small row heights
|
||||
useEffect(() => {
|
||||
if (rowHeight === "s") {
|
||||
setRowHeight("m");
|
||||
}
|
||||
}, [rowHeight, setRowHeight]);
|
||||
|
||||
const columns: LangfuseColumnDef<ModelTableRow>[] = [
|
||||
{
|
||||
accessorKey: "maintainer",
|
||||
id: "maintainer",
|
||||
enableColumnFilter: true,
|
||||
header: "Maintainer",
|
||||
size: 100,
|
||||
},
|
||||
{
|
||||
accessorKey: "modelName",
|
||||
id: "modelName",
|
||||
@@ -83,23 +91,40 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
headerTooltip: {
|
||||
description: modelConfigDescriptions.modelName,
|
||||
},
|
||||
size: 150,
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="font-mono text-xs font-semibold">
|
||||
{row.original.modelName}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
size: 120,
|
||||
},
|
||||
{
|
||||
accessorKey: "startDate",
|
||||
id: "startDate",
|
||||
header: "Start Date",
|
||||
accessorKey: "maintainer",
|
||||
id: "maintainer",
|
||||
header: "Maintainer",
|
||||
headerTooltip: {
|
||||
description: modelConfigDescriptions.startDate,
|
||||
description: modelConfigDescriptions.maintainer,
|
||||
},
|
||||
size: 100,
|
||||
size: 60,
|
||||
cell: ({ row }) => {
|
||||
const value: Date | undefined = row.getValue("startDate");
|
||||
|
||||
return value ? (
|
||||
<span className="text-xs">{value.toISOString().slice(0, 10)} </span>
|
||||
) : (
|
||||
<span className="text-xs">-</span>
|
||||
const isLangfuse = row.original.maintainer === "Langfuse";
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
{isLangfuse ? (
|
||||
<LangfuseIcon size={16} />
|
||||
) : (
|
||||
<UserCircle2Icon className="h-4 w-4" />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isLangfuse ? "Langfuse maintained" : "User maintained"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -115,104 +140,37 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
const value: string = row.getValue("matchPattern");
|
||||
|
||||
return value ? (
|
||||
<IOTableCell data={value} singleLine={rowHeight === "s"} />
|
||||
<span className="font-mono text-xs">{value}</span>
|
||||
) : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "inputPrice",
|
||||
id: "inputPrice",
|
||||
accessorKey: "prices",
|
||||
id: "prices",
|
||||
header: () => {
|
||||
return (
|
||||
<>
|
||||
Input Price{" "}
|
||||
<span className="text-xs text-muted-foreground">/ 1k units</span>
|
||||
</>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Prices {priceUnit}</span>
|
||||
<PriceUnitSelector />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
headerTooltip: {
|
||||
description: modelConfigDescriptions.inputPrice,
|
||||
},
|
||||
size: 170,
|
||||
size: 120,
|
||||
cell: ({ row }) => {
|
||||
const value: Decimal | undefined = row.getValue("inputPrice");
|
||||
const prices: Record<string, number> | undefined =
|
||||
row.getValue("prices");
|
||||
|
||||
return value ? (
|
||||
<span className="text-xs">
|
||||
{usdFormatter(value.toNumber() * 1000, 2, 8)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs">-</span>
|
||||
);
|
||||
},
|
||||
enableHiding: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "outputPrice",
|
||||
id: "outputPrice",
|
||||
headerTooltip: {
|
||||
description: modelConfigDescriptions.outputPrice,
|
||||
},
|
||||
header: () => {
|
||||
return (
|
||||
<>
|
||||
Output Price{" "}
|
||||
<span className="text-xs text-muted-foreground">/ 1k units</span>
|
||||
</>
|
||||
);
|
||||
},
|
||||
size: 170,
|
||||
cell: ({ row }) => {
|
||||
const value: Decimal | undefined = row.getValue("outputPrice");
|
||||
|
||||
return value ? (
|
||||
<span className="text-xs">
|
||||
{usdFormatter(value.toNumber() * 1000, 2, 8)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs">-</span>
|
||||
<PriceBreakdownTooltip
|
||||
modelName={row.original.modelName}
|
||||
prices={prices}
|
||||
priceUnit={priceUnit}
|
||||
rowHeight={rowHeight}
|
||||
/>
|
||||
);
|
||||
},
|
||||
enableHiding: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "totalPrice",
|
||||
id: "totalPrice",
|
||||
header: () => {
|
||||
return (
|
||||
<>
|
||||
Total Price{" "}
|
||||
<span className="text-xs text-muted-foreground">/ 1k units</span>
|
||||
</>
|
||||
);
|
||||
},
|
||||
headerTooltip: {
|
||||
description: modelConfigDescriptions.totalPrice,
|
||||
},
|
||||
size: 170,
|
||||
cell: ({ row }) => {
|
||||
const value: Decimal | undefined = row.getValue("totalPrice");
|
||||
|
||||
return value ? (
|
||||
<span className="text-xs">
|
||||
{usdFormatter(value.toNumber() * 1000, 2, 8)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs">-</span>
|
||||
);
|
||||
},
|
||||
enableHiding: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "unit",
|
||||
id: "unit",
|
||||
header: "Unit",
|
||||
headerTooltip: {
|
||||
description: modelConfigDescriptions.unit,
|
||||
},
|
||||
enableHiding: true,
|
||||
size: 110,
|
||||
},
|
||||
{
|
||||
accessorKey: "tokenizerId",
|
||||
id: "tokenizerId",
|
||||
@@ -221,7 +179,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
description: modelConfigDescriptions.tokenizerId,
|
||||
},
|
||||
enableHiding: true,
|
||||
size: 110,
|
||||
size: 120,
|
||||
},
|
||||
{
|
||||
accessorKey: "config",
|
||||
@@ -231,7 +189,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
description: modelConfigDescriptions.config,
|
||||
},
|
||||
enableHiding: true,
|
||||
size: 200,
|
||||
size: 120,
|
||||
cell: ({ row }) => {
|
||||
const value: Prisma.JsonValue | undefined = row.getValue("config");
|
||||
|
||||
@@ -243,14 +201,29 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
{
|
||||
accessorKey: "actions",
|
||||
header: "Actions",
|
||||
size: 70,
|
||||
size: 120,
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<DeleteModelButton
|
||||
projectId={projectId}
|
||||
modelId={row.original.modelId}
|
||||
isBuiltIn={row.original.maintainer === "Langfuse"}
|
||||
/>
|
||||
return row.original.maintainer !== "Langfuse" ? (
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<EditModelButton
|
||||
projectId={projectId}
|
||||
modelData={row.original.serverResponse}
|
||||
/>
|
||||
<DeleteModelButton
|
||||
projectId={projectId}
|
||||
modelData={row.original.serverResponse}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<CloneModelButton
|
||||
projectId={projectId}
|
||||
modelData={row.original.serverResponse}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -264,21 +237,16 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
columns,
|
||||
);
|
||||
|
||||
const convertToTableRow = (model: Model): ModelTableRow => {
|
||||
const convertToTableRow = (model: GetModelResult): ModelTableRow => {
|
||||
return {
|
||||
modelId: model.id,
|
||||
maintainer: model.projectId ? "User" : "Langfuse",
|
||||
modelName: model.modelName,
|
||||
matchPattern: model.matchPattern,
|
||||
startDate: model.startDate ? new Date(model.startDate) : undefined,
|
||||
inputPrice: model.inputPrice ? new Decimal(model.inputPrice) : undefined,
|
||||
outputPrice: model.outputPrice
|
||||
? new Decimal(model.outputPrice)
|
||||
: undefined,
|
||||
totalPrice: model.totalPrice ? new Decimal(model.totalPrice) : undefined,
|
||||
unit: model.unit ?? "",
|
||||
prices: model.prices,
|
||||
tokenizerId: model.tokenizerId ?? undefined,
|
||||
config: model.tokenizerConfig,
|
||||
serverResponse: model,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -320,76 +288,10 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
columnOrder={columnOrder}
|
||||
onColumnOrderChange={setColumnOrder}
|
||||
rowHeight={rowHeight}
|
||||
onRowClick={(row) => {
|
||||
router.push(`/project/${projectId}/models/${row.modelId}`);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const DeleteModelButton = ({
|
||||
modelId,
|
||||
projectId,
|
||||
isBuiltIn,
|
||||
}: {
|
||||
modelId: string;
|
||||
projectId: string;
|
||||
isBuiltIn?: boolean;
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
const capture = usePostHogClientCapture();
|
||||
const mut = api.models.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.models.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const hasAccess = useHasProjectAccess({
|
||||
projectId,
|
||||
scope: "models:CUD",
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={() => setIsOpen(!isOpen)}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
disabled={!hasAccess || isBuiltIn}
|
||||
title={
|
||||
isBuiltIn ? "Built-in models cannot be deleted" : "Delete model"
|
||||
}
|
||||
className={cn(
|
||||
isBuiltIn &&
|
||||
"disabled:pointer-events-auto disabled:cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<h2 className="text-md mb-3 font-semibold">Please confirm</h2>
|
||||
<p className="mb-3 text-sm">
|
||||
This action permanently deletes this model definition.
|
||||
</p>
|
||||
<div className="flex justify-end space-x-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
loading={mut.isLoading}
|
||||
onClick={() => {
|
||||
capture("models:delete_button_click");
|
||||
mut.mutateAsync({
|
||||
projectId,
|
||||
modelId,
|
||||
});
|
||||
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Delete Model
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -52,6 +52,7 @@ import { BatchExportTableButton } from "@/src/components/BatchExportTableButton"
|
||||
import { useClickhouse } from "@/src/components/layouts/ClickhouseAdminToggle";
|
||||
import { BreakdownTooltip } from "@/src/components/trace/BreakdownToolTip";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { useHasEntitlement } from "@/src/features/entitlements/hooks";
|
||||
|
||||
export type TracesTableRow = {
|
||||
bookmarked: boolean;
|
||||
@@ -241,6 +242,8 @@ export default function TracesTable({
|
||||
cellsLoading: !traceMetrics.data,
|
||||
});
|
||||
|
||||
const hasTraceDeletionEntitlement = useHasEntitlement("trace-deletion");
|
||||
|
||||
const columns: LangfuseColumnDef<TracesTableRow>[] = [
|
||||
{
|
||||
id: "select",
|
||||
@@ -708,7 +711,9 @@ export default function TracesTable({
|
||||
isPinned: true,
|
||||
cell: ({ row }) => {
|
||||
const traceId: TracesTableRow["id"] = row.getValue("id");
|
||||
return traceId && typeof traceId === "string" ? (
|
||||
return traceId &&
|
||||
typeof traceId === "string" &&
|
||||
hasTraceDeletionEntitlement ? (
|
||||
<DeleteButton
|
||||
itemId={traceId}
|
||||
projectId={projectId}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@/src/components/ui/tooltip";
|
||||
import { useState } from "react";
|
||||
import Decimal from "decimal.js";
|
||||
import { getMaxDecimals } from "@/src/features/models/utils";
|
||||
|
||||
interface Details {
|
||||
[key: string]: number | undefined;
|
||||
@@ -36,15 +37,6 @@ export const BreakdownTooltip = ({
|
||||
}, {})
|
||||
: details;
|
||||
|
||||
// For costs, calculate the maximum number of decimal places needed
|
||||
const getMaxDecimals = (value: number | undefined): number => {
|
||||
if (!value) return 0;
|
||||
const parts = value.toString().split(".");
|
||||
|
||||
// If no decimal point, return 0, else return length of decimal part
|
||||
return parts.length === 1 ? 0 : parts[1].length;
|
||||
};
|
||||
|
||||
const formatValueWithPadding = (value: number, maxDecimals: number) => {
|
||||
return !value
|
||||
? "0"
|
||||
|
||||
@@ -39,7 +39,8 @@ import {
|
||||
} from "@/src/components/ui/tabs-bar";
|
||||
import { useClickhouse } from "@/src/components/layouts/ClickhouseAdminToggle";
|
||||
import { BreakdownTooltip } from "./BreakdownToolTip";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { InfoIcon, PlusCircle } from "lucide-react";
|
||||
import { UpsertModelFormDrawer } from "@/src/features/models/components/UpsertModelFormDrawer";
|
||||
|
||||
export const ObservationPreview = ({
|
||||
observations,
|
||||
@@ -210,7 +211,47 @@ export const ObservationPreview = ({
|
||||
</Badge>
|
||||
) : undefined}
|
||||
{preloadedObservation.model ? (
|
||||
<Badge variant="outline">{preloadedObservation.model}</Badge>
|
||||
preloadedObservation.modelId ? (
|
||||
<Badge>
|
||||
<Link
|
||||
href={`/project/${preloadedObservation.projectId}/models/${preloadedObservation.modelId}`}
|
||||
className="flex items-center"
|
||||
title="View model details"
|
||||
>
|
||||
{preloadedObservation.model}
|
||||
</Link>
|
||||
</Badge>
|
||||
) : (
|
||||
<UpsertModelFormDrawer
|
||||
action="create"
|
||||
projectId={preloadedObservation.projectId}
|
||||
prefilledModelData={{
|
||||
modelName: preloadedObservation.model,
|
||||
prices:
|
||||
Object.keys(preloadedObservation.usageDetails)
|
||||
.length > 0
|
||||
? Object.keys(preloadedObservation.usageDetails)
|
||||
.filter((key) => key != "total")
|
||||
.reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = 0.000001;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
)
|
||||
: undefined,
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<span>{preloadedObservation.model}</span>
|
||||
<PlusCircle className="h-3 w-3" />
|
||||
</Badge>
|
||||
</UpsertModelFormDrawer>
|
||||
)
|
||||
) : null}
|
||||
{thisCost ? (
|
||||
<BreakdownTooltip
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
TabsBarList,
|
||||
TabsBarTrigger,
|
||||
} from "@/src/components/ui/tabs-bar";
|
||||
import { useHasEntitlement } from "@/src/features/entitlements/hooks";
|
||||
|
||||
export function Trace(props: {
|
||||
observations: Array<ObservationReturnType>;
|
||||
@@ -311,6 +312,8 @@ export function TracePage({
|
||||
withDefault(StringParam, "details"),
|
||||
);
|
||||
|
||||
const hasTraceDeletionEntitlement = useHasEntitlement("trace-deletion");
|
||||
|
||||
if (trace.error?.data?.code === "UNAUTHORIZED")
|
||||
return <ErrorPage message="You do not have access to this trace." />;
|
||||
|
||||
@@ -372,15 +375,17 @@ export function TracePage({
|
||||
}}
|
||||
listKey="traces"
|
||||
/>
|
||||
<DeleteButton
|
||||
itemId={traceId}
|
||||
projectId={trace.data.projectId}
|
||||
scope="traces:delete"
|
||||
invalidateFunc={() => void utils.traces.all.invalidate()}
|
||||
type="trace"
|
||||
redirectUrl={`/project/${router.query.projectId as string}/traces`}
|
||||
deleteConfirmation={trace.data.name ?? ""}
|
||||
/>
|
||||
{hasTraceDeletionEntitlement && (
|
||||
<DeleteButton
|
||||
itemId={traceId}
|
||||
projectId={trace.data.projectId}
|
||||
scope="traces:delete"
|
||||
invalidateFunc={() => void utils.traces.all.invalidate()}
|
||||
type="trace"
|
||||
redirectUrl={`/project/${router.query.projectId as string}/traces`}
|
||||
deleteConfirmation={trace.data.name ?? ""}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
|
||||
|
||||
/**
|
||||
* useLocalStorage is a hook for managing data with the localStorage API.
|
||||
* It provides cross-tab synchronization and safe interaction with localStorage.
|
||||
*
|
||||
* @param {string} localStorageKey - The key under which the value is stored in localStorage.
|
||||
* @param {T} initialValue - The initial value of the data to be stored.
|
||||
@@ -10,49 +11,148 @@ import { useState, useEffect } from "react";
|
||||
*
|
||||
* @return An array with three elements:
|
||||
* value: Current value
|
||||
* setValue: Function to update the value
|
||||
* setValue: Function to update the value and sync across tabs
|
||||
* clearValue: Function to remove value from the local storage.
|
||||
* This function will also reset the value to initial value
|
||||
*
|
||||
* @template T - The type of the data to be stored in localStorage. It should be a type that can be stringified.
|
||||
*
|
||||
* @throws Will throw an error if the stringifying the value or accessing local storage fails.
|
||||
*
|
||||
* @example
|
||||
* const [theme, setTheme, clearTheme] = useLocalStorage('theme', 'light');
|
||||
* // Use theme value
|
||||
* // Call setTheme to update
|
||||
* // Call clearTheme to reset to 'light'
|
||||
*/
|
||||
function useLocalStorage<T>(
|
||||
localStorageKey: string,
|
||||
initialValue: T,
|
||||
): [T, React.Dispatch<React.SetStateAction<T>>, () => void] {
|
||||
// Initialize state with value from localStorage or initial value
|
||||
// This initialization is only run once when the component mounts
|
||||
const [value, setValue] = useState<T>(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return initialValue;
|
||||
}
|
||||
// Return initial value if running on server-side
|
||||
if (typeof window === "undefined") return initialValue;
|
||||
|
||||
try {
|
||||
const storedValue = localStorage.getItem(localStorageKey);
|
||||
return storedValue ? (JSON.parse(storedValue) as T) : initialValue;
|
||||
const stored = localStorage.getItem(localStorageKey);
|
||||
// Parse stored value if it exists, otherwise use initial value
|
||||
return stored ? (JSON.parse(stored) as T) : initialValue;
|
||||
} catch (error) {
|
||||
console.error("Error reading from local storage", error);
|
||||
return initialValue;
|
||||
}
|
||||
});
|
||||
|
||||
const clearValue = () => {
|
||||
try {
|
||||
localStorage.removeItem(localStorageKey);
|
||||
setValue(initialValue);
|
||||
} catch (error) {
|
||||
console.error("Error clearing local storage", error);
|
||||
}
|
||||
// Helper object to safely interact with localStorage
|
||||
// Handles all error cases and provides consistent interface
|
||||
const safeLocalStorage = {
|
||||
set: (value: T) => {
|
||||
try {
|
||||
const stringified = JSON.stringify(value);
|
||||
localStorage.setItem(localStorageKey, stringified);
|
||||
return stringified;
|
||||
} catch (error) {
|
||||
console.error("Error writing to local storage", error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
remove: () => {
|
||||
try {
|
||||
localStorage.removeItem(localStorageKey);
|
||||
} catch (error) {
|
||||
console.error("Error clearing local storage", error);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Function to clear both localStorage and state
|
||||
const clearValue = () => {
|
||||
safeLocalStorage.remove();
|
||||
setValue(initialValue);
|
||||
};
|
||||
|
||||
// Sync to localStorage whenever value changes
|
||||
// This ensures localStorage always has the latest value
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(localStorageKey, JSON.stringify(value));
|
||||
} catch (error) {
|
||||
console.error("Error writing to local storage", error);
|
||||
}
|
||||
safeLocalStorage.set(value);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [localStorageKey, value]);
|
||||
|
||||
return [value, setValue, clearValue] as const;
|
||||
// Handle cross-tab synchronization
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
// Handler for native localStorage events (triggered by other tabs)
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
if (e.key === localStorageKey) {
|
||||
try {
|
||||
setValue(e.newValue ? (JSON.parse(e.newValue) as T) : initialValue);
|
||||
} catch (error) {
|
||||
console.error("Error parsing storage change", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handler for custom events (triggered within same tab)
|
||||
const handleCustomEvent = (
|
||||
e: CustomEvent<{ key: string; newValue: string }>,
|
||||
) => {
|
||||
if (e.detail.key === localStorageKey) {
|
||||
try {
|
||||
setValue(
|
||||
e.detail.newValue
|
||||
? (JSON.parse(e.detail.newValue) as T)
|
||||
: initialValue,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error parsing custom event", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for both storage events and custom events
|
||||
window.addEventListener("storage", handleStorageChange);
|
||||
window.addEventListener(
|
||||
"localStorageChange",
|
||||
handleCustomEvent as EventListener,
|
||||
);
|
||||
|
||||
// Cleanup listeners on unmount
|
||||
return () => {
|
||||
window.removeEventListener("storage", handleStorageChange);
|
||||
window.removeEventListener(
|
||||
"localStorageChange",
|
||||
handleCustomEvent as EventListener,
|
||||
);
|
||||
};
|
||||
}, [localStorageKey, initialValue]);
|
||||
|
||||
// Enhanced setValue function that also notifies other tabs
|
||||
const setValueAndNotify: React.Dispatch<React.SetStateAction<T>> = (
|
||||
newValue,
|
||||
) => {
|
||||
setValue((prev) => {
|
||||
// Handle both direct values and updater functions
|
||||
const resolvedValue =
|
||||
newValue instanceof Function ? newValue(prev) : newValue;
|
||||
const stringified = safeLocalStorage.set(resolvedValue);
|
||||
|
||||
// Dispatch custom event to notify other instances in the same tab
|
||||
if (stringified) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("localStorageChange", {
|
||||
detail: { key: localStorageKey, newValue: stringified },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return resolvedValue;
|
||||
});
|
||||
};
|
||||
|
||||
return [value, setValueAndNotify, clearValue] as const;
|
||||
}
|
||||
|
||||
export default useLocalStorage;
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const VERSION = "v3.2.0";
|
||||
export const VERSION = "v3.5.2";
|
||||
|
||||
+3
-3
@@ -31,7 +31,6 @@ import {
|
||||
import { api } from "@/src/utils/api";
|
||||
import { getScoreDataTypeIcon } from "@/src/features/scores/components/ScoreDetailColumnHelpers";
|
||||
import { MultiSelectKeyValues } from "@/src/features/scores/components/multi-select-key-values";
|
||||
import { CommandItem } from "@/src/components/ui/command";
|
||||
import { useRouter } from "next/router";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import {
|
||||
@@ -39,6 +38,7 @@ import {
|
||||
useEntitlementLimit,
|
||||
} from "@/src/features/entitlements/hooks";
|
||||
import { ActionButton } from "@/src/components/ActionButton";
|
||||
import { DropdownMenuItem } from "@/src/components/ui/dropdown-menu";
|
||||
|
||||
export const CreateOrEditAnnotationQueueButton = ({
|
||||
projectId,
|
||||
@@ -257,7 +257,7 @@ export const CreateOrEditAnnotationQueueButton = ({
|
||||
};
|
||||
})}
|
||||
controlButtons={
|
||||
<CommandItem
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
capture(
|
||||
"score_configs:manage_configs_item_click",
|
||||
@@ -269,7 +269,7 @@ export const CreateOrEditAnnotationQueueButton = ({
|
||||
}}
|
||||
>
|
||||
Manage score configs
|
||||
</CommandItem>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -77,6 +77,7 @@ import {
|
||||
} from "@/src/components/ui/dialog";
|
||||
import Link from "next/link";
|
||||
import { useHasEntitlement } from "@/src/features/entitlements/hooks";
|
||||
import { DropdownMenuItem } from "@/src/components/ui/dropdown-menu";
|
||||
|
||||
const CreateExperimentData = z.object({
|
||||
name: z
|
||||
@@ -790,13 +791,13 @@ export const CreateExperimentsForm = ({
|
||||
}
|
||||
hideClearButton
|
||||
controlButtons={
|
||||
<CommandItem
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
window.open(`/project/${projectId}/evals`, "_blank");
|
||||
}}
|
||||
>
|
||||
Manage evaluators
|
||||
</CommandItem>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
@@ -95,8 +95,20 @@ export const env = createEnv({
|
||||
AUTH_CUSTOM_ISSUER: z.string().url().optional(),
|
||||
AUTH_CUSTOM_NAME: z.string().optional(),
|
||||
AUTH_CUSTOM_SCOPE: z.string().optional(),
|
||||
AUTH_CUSTOM_CLIENT_AUTH_METHOD: z
|
||||
.enum([
|
||||
"client_secret_basic",
|
||||
"client_secret_post",
|
||||
"client_secret_jwt",
|
||||
"private_key_jwt",
|
||||
"tls_client_auth",
|
||||
"self_signed_tls_client_auth",
|
||||
"none",
|
||||
])
|
||||
.optional(),
|
||||
AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING: z.enum(["true", "false"]).optional(),
|
||||
AUTH_DOMAINS_WITH_SSO_ENFORCEMENT: z.string().optional(),
|
||||
AUTH_IGNORE_ACCOUNT_FIELDS: z.string().optional(),
|
||||
AUTH_DISABLE_USERNAME_PASSWORD: z.enum(["true", "false"]).optional(),
|
||||
AUTH_DISABLE_SIGNUP: z.enum(["true", "false"]).optional(),
|
||||
AUTH_SESSION_MAX_AGE: z.coerce
|
||||
@@ -366,8 +378,12 @@ export const env = createEnv({
|
||||
AUTH_CUSTOM_ISSUER: process.env.AUTH_CUSTOM_ISSUER,
|
||||
AUTH_CUSTOM_NAME: process.env.AUTH_CUSTOM_NAME,
|
||||
AUTH_CUSTOM_SCOPE: process.env.AUTH_CUSTOM_SCOPE,
|
||||
AUTH_CUSTOM_CLIENT_AUTH_METHOD:
|
||||
process.env.AUTH_CUSTOM_CLIENT_AUTH_METHOD,
|
||||
AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING:
|
||||
process.env.AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING,
|
||||
AUTH_IGNORE_ACCOUNT_FIELDS:
|
||||
process.env.AUTH_IGNORE_ACCOUNT_FIELDS,
|
||||
AUTH_DOMAINS_WITH_SSO_ENFORCEMENT:
|
||||
process.env.AUTH_DOMAINS_WITH_SSO_ENFORCEMENT,
|
||||
AUTH_DISABLE_USERNAME_PASSWORD: process.env.AUTH_DISABLE_USERNAME_PASSWORD,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Batch Exports
|
||||
|
||||
- Find types in shared
|
||||
- Actual export logic in worker
|
||||
@@ -0,0 +1,33 @@
|
||||
import Header from "@/src/components/layouts/header";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/src/components/ui/alert";
|
||||
import { BatchExportsTable } from "@/src/features/batch-exports/components/BatchExportsTable";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
|
||||
export function BatchExportsSettingsPage(props: { projectId: string }) {
|
||||
const hasAccess = useHasProjectAccess({
|
||||
projectId: props.projectId,
|
||||
scope: "batchExports:read",
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="Exports" level="h3" />
|
||||
<p className="mb-4 text-sm">
|
||||
Export large datasets in your preferred format via the export buttons
|
||||
across Langfuse. Exports are processed asynchronously and remain
|
||||
available for download for one hour. You will receive an email
|
||||
notification once your export is ready.
|
||||
</p>
|
||||
{hasAccess ? (
|
||||
<BatchExportsTable projectId={props.projectId} />
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertTitle>Access Denied</AlertTitle>
|
||||
<AlertDescription>
|
||||
You do not have permission to view batch exports.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { DataTable } from "@/src/components/table/data-table";
|
||||
import { type LangfuseColumnDef } from "@/src/components/table/types";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { type BatchExport } from "@langfuse/shared";
|
||||
import { StatusBadge } from "@/src/components/layouts/status-badge";
|
||||
import { NumberParam, useQueryParams, withDefault } from "use-query-params";
|
||||
import { ActionButton } from "@/src/components/ActionButton";
|
||||
import { DownloadIcon, InfoIcon } from "lucide-react";
|
||||
import { Avatar, AvatarImage } from "@/src/components/ui/avatar";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/src/components/ui/tooltip";
|
||||
|
||||
export function BatchExportsTable(props: { projectId: string }) {
|
||||
const [paginationState, setPaginationState] = useQueryParams({
|
||||
pageIndex: withDefault(NumberParam, 0),
|
||||
pageSize: withDefault(NumberParam, 10),
|
||||
});
|
||||
|
||||
const batchExports = api.batchExport.all.useQuery({
|
||||
projectId: props.projectId,
|
||||
limit: paginationState.pageSize,
|
||||
page: paginationState.pageIndex,
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
id: "name",
|
||||
header: "Name",
|
||||
size: 200,
|
||||
cell: ({ row }) => {
|
||||
const name = row.getValue("name") as string;
|
||||
const { createdAt, finishedAt } = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="whitespace-break-spaces">{name}</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="size-3 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="space-y-1">
|
||||
<div>Created: {new Date(createdAt).toLocaleString()}</div>
|
||||
<div>
|
||||
Finished:{" "}
|
||||
{finishedAt ? new Date(finishedAt).toLocaleString() : "-"}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
id: "status",
|
||||
header: "Status",
|
||||
size: 90,
|
||||
cell: (row) => {
|
||||
const status = row.getValue() as string;
|
||||
return (
|
||||
<StatusBadge type={status.toLowerCase()} className="capitalize" />
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "url",
|
||||
id: "url",
|
||||
header: "Download URL",
|
||||
size: 130,
|
||||
cell: (info) => {
|
||||
const url = info.getValue() as string | null;
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
if (url === "expired") {
|
||||
return <span className="text-muted-foreground">Expired</span>;
|
||||
}
|
||||
return (
|
||||
<ActionButton href={url} icon={<DownloadIcon size={16} />} size="sm">
|
||||
Download
|
||||
</ActionButton>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "format",
|
||||
id: "format",
|
||||
header: "Format",
|
||||
size: 70,
|
||||
},
|
||||
{
|
||||
accessorKey: "user",
|
||||
id: "user",
|
||||
header: "Created By",
|
||||
size: 150,
|
||||
cell: ({ row }) => {
|
||||
const user = row.getValue("user") as {
|
||||
name: string | null;
|
||||
image: string | null;
|
||||
} | null;
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Avatar className="h-7 w-7">
|
||||
<AvatarImage
|
||||
src={user?.image ?? undefined}
|
||||
alt={user?.name ?? "User Avatar"}
|
||||
/>
|
||||
</Avatar>
|
||||
<span>{user?.name ?? "Unknown"}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "log",
|
||||
id: "log",
|
||||
header: "Log",
|
||||
size: 300,
|
||||
cell: (row) => {
|
||||
const log = row.getValue() as string | null;
|
||||
return log ?? null;
|
||||
},
|
||||
},
|
||||
] as LangfuseColumnDef<BatchExport>[];
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={
|
||||
batchExports.isLoading
|
||||
? { isLoading: true, isError: false }
|
||||
: batchExports.isError
|
||||
? {
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: batchExports.error.message,
|
||||
}
|
||||
: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: batchExports.data.exports,
|
||||
}
|
||||
}
|
||||
pagination={{
|
||||
totalCount: batchExports.data?.totalCount ?? null,
|
||||
onChange: setPaginationState,
|
||||
state: paginationState,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { auditLog } from "@/src/features/audit-logs/auditLog";
|
||||
import { throwIfNoProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import {
|
||||
createTRPCRouter,
|
||||
protectedProjectProcedure,
|
||||
} from "@/src/server/api/trpc";
|
||||
import {
|
||||
BatchExportStatus,
|
||||
CreateBatchExportSchema,
|
||||
paginationZod,
|
||||
} from "@langfuse/shared";
|
||||
import {
|
||||
BatchExportQueue,
|
||||
logger,
|
||||
QueueJobs,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
|
||||
export const batchExportRouter = createTRPCRouter({
|
||||
create: protectedProjectProcedure
|
||||
.input(CreateBatchExportSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
// Check permissions, esp. projectId
|
||||
throwIfNoProjectAccess({
|
||||
session: ctx.session,
|
||||
projectId: input.projectId,
|
||||
scope: "batchExports:create",
|
||||
});
|
||||
|
||||
const { projectId, query, format, name } = input;
|
||||
console.log(
|
||||
"[TRPC] Creating export job",
|
||||
JSON.stringify(input, null, 2),
|
||||
);
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Create export job
|
||||
const exportJob = await ctx.prisma.batchExport.create({
|
||||
data: {
|
||||
projectId,
|
||||
userId,
|
||||
status: BatchExportStatus.QUEUED,
|
||||
name,
|
||||
format,
|
||||
query,
|
||||
},
|
||||
});
|
||||
|
||||
// Create audit log
|
||||
await auditLog({
|
||||
session: ctx.session,
|
||||
resourceType: "batchExport",
|
||||
resourceId: exportJob.id,
|
||||
projectId,
|
||||
action: "create",
|
||||
after: exportJob,
|
||||
});
|
||||
|
||||
// Notify worker
|
||||
await BatchExportQueue.getInstance()?.add(QueueJobs.BatchExportJob, {
|
||||
id: exportJob.id, // Use the batchExportId to deduplicate when the same job is sent multiple times
|
||||
name: QueueJobs.BatchExportJob,
|
||||
timestamp: new Date(),
|
||||
payload: {
|
||||
batchExportId: exportJob.id,
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
if (e instanceof TRPCError) {
|
||||
throw e;
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Creating export job failed.",
|
||||
});
|
||||
}
|
||||
}),
|
||||
all: protectedProjectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
...paginationZod,
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
throwIfNoProjectAccess({
|
||||
session: ctx.session,
|
||||
projectId: input.projectId,
|
||||
scope: "batchExports:read",
|
||||
});
|
||||
|
||||
const [exports, totalCount] = await Promise.all([
|
||||
ctx.prisma.batchExport.findMany({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
},
|
||||
take: input.limit,
|
||||
skip: input.page * input.limit,
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
}),
|
||||
ctx.prisma.batchExport.count({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Look up users for each export
|
||||
const userIds = [...new Set(exports.map((e) => e.userId))];
|
||||
const users = await ctx.prisma.user.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: userIds,
|
||||
},
|
||||
organizationMemberships: {
|
||||
some: {
|
||||
organization: {
|
||||
projects: {
|
||||
some: {
|
||||
id: input.projectId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
image: true,
|
||||
},
|
||||
});
|
||||
|
||||
const userMap = new Map(users.map((u) => [u.id, u]));
|
||||
|
||||
const exportsWithExpiration = exports.map((e) => {
|
||||
const { finishedAt, url, ...rest } = e;
|
||||
|
||||
let isExpired = false;
|
||||
if (finishedAt) {
|
||||
const finishTime = new Date(finishedAt).getTime();
|
||||
const now = new Date().getTime();
|
||||
const oneHourInMs = 60 * 60 * 1000;
|
||||
isExpired = now - finishTime > oneHourInMs;
|
||||
}
|
||||
|
||||
return {
|
||||
...rest,
|
||||
finishedAt,
|
||||
url: isExpired ? "expired" : url,
|
||||
user: userMap.get(e.userId) ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
exports: exportsWithExpiration,
|
||||
totalCount,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -151,7 +151,9 @@ export const ModelUsageChart = ({
|
||||
});
|
||||
|
||||
const usageData = Array.from(usageTypeMap.values()).flat();
|
||||
const currentModels = [...new Set(usageData.map((row) => row.model))];
|
||||
const currentModels = [
|
||||
...new Set(usageData.map((row) => row.model).filter(Boolean)),
|
||||
];
|
||||
|
||||
const unitsByType =
|
||||
usageData && allModels.length > 0
|
||||
@@ -207,13 +209,19 @@ export const ModelUsageChart = ({
|
||||
|
||||
const totalCost = usageData?.reduce(
|
||||
(acc, curr) =>
|
||||
acc + (curr.usageType === "total" ? (curr.cost as number) : 0),
|
||||
acc +
|
||||
(curr.usageType === "total" && !isNaN(curr.cost as number)
|
||||
? (curr.cost as number)
|
||||
: 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const totalTokens = usageData?.reduce(
|
||||
(acc, curr) =>
|
||||
acc + (curr.usageType === "total" ? (curr.units as number) : 0),
|
||||
acc +
|
||||
(curr.usageType === "total" && !isNaN(curr.units as number)
|
||||
? (curr.units as number)
|
||||
: 0),
|
||||
0,
|
||||
);
|
||||
|
||||
@@ -280,19 +288,18 @@ export const ModelUsageChart = ({
|
||||
<CommandInput placeholder="Search models..." />
|
||||
<CommandEmpty>No model found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
onSelect={handleSelectAll}
|
||||
className="border-b border-gray-200 bg-gray-50 font-medium hover:bg-gray-100"
|
||||
>
|
||||
<CommandItem onSelect={handleSelectAll}>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
isAllSelected ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span>Select All</span>
|
||||
<span>
|
||||
<p className="font-semibold">Select All</p>
|
||||
</span>
|
||||
</CommandItem>
|
||||
<CommandSeparator />
|
||||
<CommandSeparator className="my-1" />
|
||||
<CommandList>
|
||||
{allModels.map((model) => (
|
||||
<CommandItem
|
||||
@@ -313,7 +320,7 @@ export const ModelUsageChart = ({
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{model}
|
||||
{!model || model === "" ? <i>none</i> : model}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandList>
|
||||
|
||||
@@ -1,31 +1,19 @@
|
||||
import { api } from "@/src/utils/api";
|
||||
|
||||
import {
|
||||
type ScoreSource,
|
||||
type FilterState,
|
||||
type ScoreDataType,
|
||||
} from "@langfuse/shared";
|
||||
import { type FilterState } from "@langfuse/shared";
|
||||
import { createTracesTimeFilter } from "@/src/features/dashboard/lib/dashboard-utils";
|
||||
import {
|
||||
type DashboardDateRangeAggregationOption,
|
||||
dashboardDateRangeAggregationSettings,
|
||||
} from "@/src/utils/date-range-utils";
|
||||
import React, { useMemo } from "react";
|
||||
import { BarChart } from "@tremor/react";
|
||||
import { Card } from "@/src/components/ui/card";
|
||||
import { getColorsForCategories } from "@/src/features/dashboard/utils/getColorsForCategories";
|
||||
import {
|
||||
isEmptyBarChart,
|
||||
transformCategoricalScoresToChartData,
|
||||
} from "@/src/features/dashboard/lib/score-analytics-utils";
|
||||
import { NoDataOrLoading } from "@/src/components/NoDataOrLoading";
|
||||
import { useClickhouse } from "@/src/components/layouts/ClickhouseAdminToggle";
|
||||
import { DashboardCategoricalScoreAdapter } from "@/src/features/scores/adapters";
|
||||
import { type ScoreData } from "@/src/features/scores/types";
|
||||
import { CategoricalChart } from "@/src/features/scores/components/ScoreChart";
|
||||
|
||||
export function CategoricalScoreChart(props: {
|
||||
projectId: string;
|
||||
name: string;
|
||||
source: ScoreSource;
|
||||
dataType: ScoreDataType;
|
||||
scoreData: ScoreData;
|
||||
globalFilterState: FilterState;
|
||||
agg?: DashboardDateRangeAggregationOption;
|
||||
}) {
|
||||
@@ -45,19 +33,19 @@ export function CategoricalScoreChart(props: {
|
||||
{
|
||||
type: "string",
|
||||
column: "scoreName",
|
||||
value: props.name,
|
||||
value: props.scoreData.name,
|
||||
operator: "=",
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
column: "scoreSource",
|
||||
value: props.source,
|
||||
value: props.scoreData.source,
|
||||
operator: "=",
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
column: "scoreDataType",
|
||||
value: props.dataType,
|
||||
value: props.scoreData.dataType,
|
||||
operator: "=",
|
||||
},
|
||||
],
|
||||
@@ -99,40 +87,23 @@ export function CategoricalScoreChart(props: {
|
||||
);
|
||||
|
||||
const { chartData, chartLabels } = useMemo(() => {
|
||||
return scores.data
|
||||
? transformCategoricalScoresToChartData(
|
||||
scores.data,
|
||||
"scoreTimestamp",
|
||||
props.agg,
|
||||
)
|
||||
: { chartData: [], chartLabels: [] };
|
||||
if (!scores.data) return { chartData: [], chartLabels: [] };
|
||||
|
||||
const adapter = new DashboardCategoricalScoreAdapter(
|
||||
scores.data,
|
||||
"scoreTimestamp",
|
||||
props.agg,
|
||||
);
|
||||
return adapter.toChartData();
|
||||
}, [scores.data, props.agg]);
|
||||
|
||||
const barCategoryGap = (chartLength: number): string => {
|
||||
if (chartLength > 7) return "10%";
|
||||
if (chartLength > 5) return "20%";
|
||||
if (chartLength > 3) return "30%";
|
||||
else return "40%";
|
||||
};
|
||||
const colors = getColorsForCategories(chartLabels);
|
||||
|
||||
return isEmptyBarChart({ data: chartData }) ? (
|
||||
<NoDataOrLoading isLoading={scores.isLoading} />
|
||||
) : (
|
||||
<Card className="min-h-[9rem] w-full flex-1 rounded-tremor-default border">
|
||||
<BarChart
|
||||
className="mt-4"
|
||||
data={chartData}
|
||||
index="binLabel"
|
||||
categories={chartLabels}
|
||||
colors={colors}
|
||||
valueFormatter={(number: number) =>
|
||||
Intl.NumberFormat("en-US").format(number).toString()
|
||||
}
|
||||
yAxisWidth={48}
|
||||
barCategoryGap={barCategoryGap(chartData.length)}
|
||||
stack={!!props.agg}
|
||||
/>
|
||||
</Card>
|
||||
return (
|
||||
<CategoricalChart
|
||||
chartData={chartData}
|
||||
chartLabels={chartLabels}
|
||||
isLoading={scores.isLoading}
|
||||
className="min-h-[9rem] flex-1"
|
||||
stack={!!props.agg}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
isBooleanDataType,
|
||||
isCategoricalDataType,
|
||||
isNumericDataType,
|
||||
toOrderedScoresList,
|
||||
} from "@/src/features/scores/lib/helpers";
|
||||
import { getScoreDataTypeIcon } from "@/src/features/scores/components/ScoreDetailColumnHelpers";
|
||||
import { NumericScoreTimeSeriesChart } from "@/src/features/dashboard/components/score-analytics/NumericScoreTimeSeriesChart";
|
||||
@@ -46,11 +47,14 @@ export function ScoreAnalytics(props: {
|
||||
);
|
||||
|
||||
const { scoreAnalyticsOptions, scoreKeyToData } = useMemo(() => {
|
||||
const scoreAnalyticsOptions =
|
||||
scoreKeysAndProps.data?.map(({ key, name, dataType, source }) => ({
|
||||
key,
|
||||
value: `${getScoreDataTypeIcon(dataType)} ${name} (${source.toLowerCase()})`,
|
||||
})) ?? [];
|
||||
const scoreAnalyticsOptions = scoreKeysAndProps.data
|
||||
? toOrderedScoresList(scoreKeysAndProps.data).map(
|
||||
({ key, name, dataType, source }) => ({
|
||||
key,
|
||||
value: `${getScoreDataTypeIcon(dataType)} ${name} (${source.toLowerCase()})`,
|
||||
}),
|
||||
)
|
||||
: [];
|
||||
|
||||
return {
|
||||
scoreAnalyticsOptions,
|
||||
@@ -123,19 +127,17 @@ export function ScoreAnalytics(props: {
|
||||
{(isCategoricalDataType(dataType) ||
|
||||
isBooleanDataType(dataType)) && (
|
||||
<CategoricalScoreChart
|
||||
source={source}
|
||||
name={name}
|
||||
dataType={dataType}
|
||||
projectId={props.projectId}
|
||||
scoreData={scoreData}
|
||||
globalFilterState={props.globalFilterState}
|
||||
/>
|
||||
)}
|
||||
{isNumericDataType(dataType) && (
|
||||
<NumericScoreHistogram
|
||||
projectId={props.projectId}
|
||||
source={source}
|
||||
name={name}
|
||||
dataType={dataType}
|
||||
projectId={props.projectId}
|
||||
globalFilterState={props.globalFilterState}
|
||||
/>
|
||||
)}
|
||||
@@ -150,11 +152,9 @@ export function ScoreAnalytics(props: {
|
||||
{(isCategoricalDataType(dataType) ||
|
||||
isBooleanDataType(dataType)) && (
|
||||
<CategoricalScoreChart
|
||||
agg={props.agg}
|
||||
source={source}
|
||||
name={name}
|
||||
dataType={dataType}
|
||||
projectId={props.projectId}
|
||||
agg={props.agg}
|
||||
scoreData={scoreData}
|
||||
globalFilterState={props.globalFilterState}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,16 +1,36 @@
|
||||
import { type DashboardDateRangeAggregationOption } from "@/src/utils/date-range-utils";
|
||||
import { type DatabaseRow } from "@/src/server/api/services/queryBuilder";
|
||||
import {
|
||||
type CategoryCounts,
|
||||
type ChartBin,
|
||||
type HistogramBin,
|
||||
} from "@/src/features/scores/types";
|
||||
import { type RouterOutputs } from "@/src/utils/api";
|
||||
|
||||
// types
|
||||
type HistogramBin = { binLabel: string; count: number };
|
||||
type CategoryCounts = Record<string, number>;
|
||||
type ChartBin = { binLabel: string } & CategoryCounts;
|
||||
export const RESOURCE_METRICS = [
|
||||
{
|
||||
key: "latency",
|
||||
value: "Latency",
|
||||
objectKey: "avgLatency",
|
||||
label: "Latency (ms)",
|
||||
},
|
||||
{
|
||||
key: "cost",
|
||||
value: "Cost",
|
||||
objectKey: "avgTotalCost",
|
||||
label: "Average Total Cost ($)",
|
||||
},
|
||||
];
|
||||
|
||||
// numeric score analytics helpers
|
||||
function round(value: number, precision = 2) {
|
||||
return parseFloat(value.toFixed(precision));
|
||||
}
|
||||
|
||||
export function uniqueAndSort(labels: string[]): string[] {
|
||||
return Array.from(new Set(labels)).sort();
|
||||
}
|
||||
|
||||
function computeBinSize(
|
||||
minBins: number,
|
||||
maxBins: number,
|
||||
@@ -125,8 +145,112 @@ function groupCategoricalScoreDataByTimestamp(
|
||||
);
|
||||
}
|
||||
|
||||
function uniqueAndSort(labels: string[]): string[] {
|
||||
return Array.from(new Set(labels)).sort();
|
||||
type ChartAccumulator = Map<
|
||||
string,
|
||||
{ chartData: ChartBin[]; chartLabels: string[] }
|
||||
>;
|
||||
|
||||
function initializeOrGetChartData(acc: ChartAccumulator, key: string) {
|
||||
if (!acc.has(key)) {
|
||||
acc.set(key, { chartData: [], chartLabels: [] });
|
||||
}
|
||||
return acc.get(key)!;
|
||||
}
|
||||
|
||||
function createNumericScoreData(run: string, score: number, scoreName: string) {
|
||||
return {
|
||||
chartLabels: [scoreName],
|
||||
chartBin: {
|
||||
binLabel: run,
|
||||
[scoreName]: score,
|
||||
} as ChartBin,
|
||||
};
|
||||
}
|
||||
|
||||
function createCategoricalScoreData(
|
||||
run: string,
|
||||
valueCounts: Array<{ value: string; count: number }>,
|
||||
values: string[],
|
||||
) {
|
||||
const categoryCounts = valueCounts.reduce(
|
||||
(counts, { value, count }) => ({
|
||||
...counts,
|
||||
[value]: count,
|
||||
}),
|
||||
{} as CategoryCounts,
|
||||
);
|
||||
|
||||
return {
|
||||
chartLabels: values,
|
||||
chartBin: {
|
||||
binLabel: run,
|
||||
...categoryCounts,
|
||||
} as ChartBin,
|
||||
};
|
||||
}
|
||||
|
||||
function addMetricToAccumulator(
|
||||
acc: ChartAccumulator,
|
||||
key: string,
|
||||
chartBin: ChartBin,
|
||||
chartLabels: string[],
|
||||
) {
|
||||
const current = initializeOrGetChartData(acc, key);
|
||||
acc.set(key, {
|
||||
chartData: [...current.chartData, chartBin],
|
||||
chartLabels,
|
||||
});
|
||||
}
|
||||
|
||||
export function transformAggregatedRunMetricsToChartData(
|
||||
runMetrics: RouterOutputs["datasets"]["runsByDatasetIdMetrics"]["runs"],
|
||||
scoreIdToName: Map<string, string>,
|
||||
) {
|
||||
const reversedMetrics = runMetrics.slice().reverse();
|
||||
|
||||
return reversedMetrics.reduce((acc, run) => {
|
||||
// Handle scores
|
||||
Object.entries(run.scores ?? {}).forEach(([scoreId, score]) => {
|
||||
const scoreData =
|
||||
score.type === "NUMERIC"
|
||||
? createNumericScoreData(
|
||||
run.name,
|
||||
score.average,
|
||||
scoreIdToName.get(scoreId) ?? scoreId,
|
||||
)
|
||||
: createCategoricalScoreData(
|
||||
run.name,
|
||||
score.valueCounts,
|
||||
score.values,
|
||||
);
|
||||
|
||||
addMetricToAccumulator(
|
||||
acc,
|
||||
scoreId,
|
||||
scoreData.chartBin,
|
||||
scoreData.chartLabels,
|
||||
);
|
||||
});
|
||||
|
||||
// Handle resource metrics
|
||||
RESOURCE_METRICS.forEach(({ key, objectKey }) => {
|
||||
const resourceValue = run[objectKey as keyof typeof run];
|
||||
const resourceData = createNumericScoreData(
|
||||
run.name,
|
||||
!!resourceValue ? Number(resourceValue) : 0,
|
||||
key,
|
||||
);
|
||||
|
||||
addMetricToAccumulator(
|
||||
acc,
|
||||
key,
|
||||
resourceData.chartBin,
|
||||
resourceData.chartLabels,
|
||||
);
|
||||
});
|
||||
|
||||
return acc;
|
||||
}, new Map());
|
||||
}
|
||||
|
||||
export function transformCategoricalScoresToChartData(
|
||||
@@ -155,11 +279,11 @@ export function transformCategoricalScoresToChartData(
|
||||
chartData.push({ ...categoryCounts, binLabel: timestamp } as ChartBin);
|
||||
});
|
||||
|
||||
return { chartData, chartLabels: uniqueAndSort(chartLabels) };
|
||||
return { chartData, chartLabels };
|
||||
}
|
||||
}
|
||||
|
||||
export function isEmptyBarChart({ data }: { data: ChartBin[] }) {
|
||||
export function isEmptyChart({ data }: { data: ChartBin[] }) {
|
||||
return (
|
||||
data.length === 0 || data.every((item) => Object.keys(item).length === 1)
|
||||
);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { RESOURCE_METRICS } from "@/src/features/dashboard/lib/score-analytics-utils";
|
||||
import { MultiSelectKeyValues } from "@/src/features/scores/components/multi-select-key-values";
|
||||
import { ChartColumnBig } from "lucide-react";
|
||||
|
||||
export function DatasetAnalytics(props: {
|
||||
projectId: string;
|
||||
scoreOptions: { key: string; value: string }[];
|
||||
selectedMetrics: string[];
|
||||
setSelectedMetrics: (metrics: string[]) => void;
|
||||
}) {
|
||||
return (
|
||||
<MultiSelectKeyValues
|
||||
className="max-w-fit"
|
||||
placeholder="Search..."
|
||||
title="Charts"
|
||||
iconLeft={<ChartColumnBig className="mr-1 h-4 w-4" />}
|
||||
hideClearButton
|
||||
onValueChange={(values, changedValue, selectedKeys) => {
|
||||
if (values.length === 0) props.setSelectedMetrics([]);
|
||||
|
||||
if (changedValue) {
|
||||
if (selectedKeys?.has(changedValue)) {
|
||||
props.setSelectedMetrics([...props.selectedMetrics, changedValue]);
|
||||
} else {
|
||||
props.setSelectedMetrics(
|
||||
props.selectedMetrics.filter((key) => key !== changedValue),
|
||||
);
|
||||
}
|
||||
}
|
||||
}}
|
||||
values={props.selectedMetrics}
|
||||
options={RESOURCE_METRICS}
|
||||
groupedOptions={[{ label: "Scores", options: props.scoreOptions }]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { api } from "@/src/utils/api";
|
||||
import { formatIntervalSeconds } from "@/src/utils/dates";
|
||||
import { useQueryParams, withDefault, NumberParam } from "use-query-params";
|
||||
import { type RouterOutput } from "@/src/utils/types";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { usdFormatter } from "../../../utils/numbers";
|
||||
import { DataTableToolbar } from "@/src/components/table/data-table-toolbar";
|
||||
import useColumnVisibility from "@/src/features/column-visibility/hooks/useColumnVisibility";
|
||||
@@ -14,6 +14,7 @@ import { type Prisma } from "@langfuse/shared";
|
||||
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
|
||||
import { IOTableCell } from "@/src/components/ui/CodeJsonViewer";
|
||||
import {
|
||||
getScoreDataTypeIcon,
|
||||
getScoreGroupColumnProps,
|
||||
verifyAndPrefixScoreDataAgainstKeys,
|
||||
} from "@/src/features/scores/components/ScoreDetailColumnHelpers";
|
||||
@@ -36,6 +37,14 @@ import Link from "next/link";
|
||||
import { useClickhouse } from "@/src/components/layouts/ClickhouseAdminToggle";
|
||||
import { joinTableCoreAndMetrics } from "@/src/components/table/utils/joinTableCoreAndMetrics";
|
||||
import { Skeleton } from "@/src/components/ui/skeleton";
|
||||
import {
|
||||
RESOURCE_METRICS,
|
||||
transformAggregatedRunMetricsToChartData,
|
||||
} from "@/src/features/dashboard/lib/score-analytics-utils";
|
||||
import { TimeseriesChart } from "@/src/features/scores/components/TimeseriesChart";
|
||||
import { Card, CardContent } from "@/src/components/ui/card";
|
||||
import { CompareViewAdapter } from "@/src/features/scores/adapters";
|
||||
import { isNumericDataType } from "@/src/features/scores/lib/helpers";
|
||||
|
||||
export type DatasetRunRowData = {
|
||||
id: string;
|
||||
@@ -93,6 +102,8 @@ const DatasetRunTableMultiSelectAction = ({
|
||||
export function DatasetRunsTable(props: {
|
||||
projectId: string;
|
||||
datasetId: string;
|
||||
selectedMetrics: string[];
|
||||
setScoreOptions: (options: { key: string; value: string }[]) => void;
|
||||
menuItems?: React.ReactNode;
|
||||
}) {
|
||||
const [paginationState, setPaginationState] = useQueryParams({
|
||||
@@ -105,6 +116,8 @@ export function DatasetRunsTable(props: {
|
||||
"datasetRuns",
|
||||
"s",
|
||||
);
|
||||
const { setScoreOptions } = props;
|
||||
|
||||
const runs = api.datasets.runsByDatasetId.useQuery({
|
||||
projectId: props.projectId,
|
||||
datasetId: props.datasetId,
|
||||
@@ -149,6 +162,37 @@ export function DatasetRunsTable(props: {
|
||||
showAggregateViewOnly: true,
|
||||
});
|
||||
|
||||
const scoreIdToName = useMemo(() => {
|
||||
return new Map(scoreKeysAndProps.map((obj) => [obj.key, obj.name]) ?? []);
|
||||
}, [scoreKeysAndProps]);
|
||||
|
||||
const runAggregatedMetrics = useMemo(() => {
|
||||
return transformAggregatedRunMetricsToChartData(
|
||||
runsMetrics.data?.runs ?? [],
|
||||
scoreIdToName,
|
||||
);
|
||||
}, [runsMetrics.data, scoreIdToName]);
|
||||
|
||||
const { scoreAnalyticsOptions, scoreKeyToData } = useMemo(() => {
|
||||
const scoreAnalyticsOptions = scoreKeysAndProps
|
||||
? scoreKeysAndProps.map(({ key, name, dataType, source }) => ({
|
||||
key,
|
||||
value: `${getScoreDataTypeIcon(dataType)} ${name} (${source.toLowerCase()})`,
|
||||
}))
|
||||
: [];
|
||||
|
||||
return {
|
||||
scoreAnalyticsOptions,
|
||||
scoreKeyToData: new Map(
|
||||
scoreKeysAndProps.map((obj) => [obj.key, obj]) ?? [],
|
||||
),
|
||||
};
|
||||
}, [scoreKeysAndProps]);
|
||||
|
||||
useEffect(() => {
|
||||
setScoreOptions(scoreAnalyticsOptions);
|
||||
}, [scoreAnalyticsOptions, setScoreOptions]);
|
||||
|
||||
const columns: LangfuseColumnDef<DatasetRunRowData>[] = [
|
||||
{
|
||||
id: "select",
|
||||
@@ -348,6 +392,51 @@ export function DatasetRunsTable(props: {
|
||||
|
||||
return (
|
||||
<>
|
||||
{Boolean(props.selectedMetrics.length) &&
|
||||
Boolean(runAggregatedMetrics?.size) && (
|
||||
<Card className="my-4 max-h-[25dvh] md:max-h-[30dvh]">
|
||||
<CardContent className="mt-2 h-full">
|
||||
<div className="flex h-full w-full gap-4 overflow-x-auto">
|
||||
{props.selectedMetrics.map((key) => {
|
||||
const adapter = new CompareViewAdapter(
|
||||
runAggregatedMetrics,
|
||||
key,
|
||||
);
|
||||
const { chartData, chartLabels } = adapter.toChartData();
|
||||
|
||||
const scoreData = scoreKeyToData.get(key);
|
||||
if (!scoreData)
|
||||
return (
|
||||
<TimeseriesChart
|
||||
key={key}
|
||||
chartData={chartData}
|
||||
chartLabels={chartLabels}
|
||||
title={
|
||||
RESOURCE_METRICS.find((metric) => metric.key === key)
|
||||
?.label ?? key
|
||||
}
|
||||
type="numeric"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<TimeseriesChart
|
||||
key={key}
|
||||
chartData={chartData}
|
||||
chartLabels={chartLabels}
|
||||
title={`${getScoreDataTypeIcon(scoreData.dataType)} ${scoreData.name} (${scoreData.source.toLowerCase()})`}
|
||||
type={
|
||||
isNumericDataType(scoreData.dataType)
|
||||
? "numeric"
|
||||
: "categorical"
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<DataTableToolbar
|
||||
columns={columns}
|
||||
columnVisibility={columnVisibility}
|
||||
|
||||
@@ -858,8 +858,9 @@ async function runsByDatasetIdPg(
|
||||
projectId: string;
|
||||
datasetId: string;
|
||||
queryClickhouse: boolean;
|
||||
page: number;
|
||||
limit: number;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
runIds?: string[];
|
||||
},
|
||||
) {
|
||||
const scoresByRunId = await prisma.$queryRaw<
|
||||
@@ -889,10 +890,11 @@ async function runsByDatasetIdPg(
|
||||
runs.dataset_id = ${input.datasetId}
|
||||
AND runs.project_id = ${input.projectId}
|
||||
AND s.score IS NOT NULL
|
||||
${input.runIds ? Prisma.sql`AND runs.id IN (${Prisma.join(input.runIds)})` : Prisma.empty}
|
||||
GROUP BY
|
||||
runs.id
|
||||
LIMIT ${input.limit}
|
||||
OFFSET ${input.page * input.limit}
|
||||
${input.limit ? Prisma.sql`LIMIT ${input.limit}` : Prisma.empty}
|
||||
${input.page && input.limit ? Prisma.sql`OFFSET ${input.page * input.limit}` : Prisma.empty}
|
||||
`);
|
||||
|
||||
const runs = await prisma.$queryRaw<
|
||||
@@ -983,10 +985,11 @@ async function runsByDatasetIdPg(
|
||||
WHERE
|
||||
runs.dataset_id = ${input.datasetId}
|
||||
AND runs.project_id = ${input.projectId}
|
||||
${input.runIds ? Prisma.sql`AND runs.id IN (${Prisma.join(input.runIds)})` : Prisma.empty}
|
||||
ORDER BY
|
||||
runs.created_at DESC
|
||||
LIMIT ${input.limit}
|
||||
OFFSET ${input.page * input.limit}
|
||||
${input.limit ? Prisma.sql`LIMIT ${input.limit}` : Prisma.empty}
|
||||
${input.page && input.limit ? Prisma.sql`OFFSET ${input.page * input.limit}` : Prisma.empty}
|
||||
`);
|
||||
|
||||
const totalRuns = await prisma.datasetRuns.count({
|
||||
@@ -1002,7 +1005,7 @@ async function runsByDatasetIdPg(
|
||||
...run,
|
||||
scores: aggregateScores(
|
||||
scoresByRunId.flatMap((s) => (s.runId === run.id ? s.scores : [])),
|
||||
) as ScoreAggregate | undefined
|
||||
) as ScoreAggregate | undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
filterAndValidateDbScoreList,
|
||||
paginationZod,
|
||||
Prisma,
|
||||
type PrismaClient,
|
||||
type DatasetRunItems,
|
||||
optionalPaginationZod,
|
||||
} from "@langfuse/shared";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { v4 } from "uuid";
|
||||
@@ -32,7 +32,8 @@ export const datasetRunsTableSchema = z.object({
|
||||
projectId: z.string(),
|
||||
datasetId: z.string(),
|
||||
queryClickhouse: z.boolean().optional().default(false),
|
||||
...paginationZod,
|
||||
runIds: z.array(z.string()).optional(),
|
||||
...optionalPaginationZod,
|
||||
});
|
||||
|
||||
type PostgresRunItem = {
|
||||
@@ -235,10 +236,11 @@ export const getDatasetRunsFromPostgres = async (
|
||||
WHERE
|
||||
d.id = ${input.datasetId}
|
||||
AND d.project_id = ${input.projectId}
|
||||
${input.runIds?.length ? Prisma.sql`AND runs.id IN (${Prisma.join(input.runIds)})` : Prisma.empty}
|
||||
GROUP BY runs.id, runs.name, runs.description, runs.metadata, runs.created_at, runs.updated_at
|
||||
ORDER BY runs.created_at DESC
|
||||
LIMIT ${input.limit}
|
||||
OFFSET ${input.page * input.limit}
|
||||
${input.limit ? Prisma.sql`LIMIT ${input.limit}` : Prisma.empty}
|
||||
${input.page && input.limit ? Prisma.sql`OFFSET ${input.page * input.limit}` : Prisma.empty}
|
||||
`,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ const entitlements = [
|
||||
"self-host-ui-customization",
|
||||
"self-host-allowed-organization-creators",
|
||||
"prompt-experiments",
|
||||
"trace-deletion",
|
||||
] as const;
|
||||
export type Entitlement = (typeof entitlements)[number];
|
||||
|
||||
@@ -24,6 +25,8 @@ const cloudAllPlansEntitlements: Entitlement[] = [
|
||||
"prompt-experiments",
|
||||
];
|
||||
|
||||
const selfHostedAllPlansEntitlements: Entitlement[] = ["trace-deletion"];
|
||||
|
||||
// Entitlement Limits: Limits on the number of resources that can be created/used
|
||||
const entitlementLimits = [
|
||||
"annotation-queue-count",
|
||||
@@ -78,7 +81,7 @@ export const entitlementAccess: Record<
|
||||
},
|
||||
},
|
||||
oss: {
|
||||
entitlements: [],
|
||||
entitlements: [...selfHostedAllPlansEntitlements],
|
||||
entitlementLimits: {
|
||||
"annotation-queue-count": 0,
|
||||
"organization-member-count": false,
|
||||
@@ -89,6 +92,7 @@ export const entitlementAccess: Record<
|
||||
},
|
||||
"self-hosted:pro": {
|
||||
entitlements: [
|
||||
...selfHostedAllPlansEntitlements,
|
||||
"annotation-queues",
|
||||
"model-based-evaluations",
|
||||
"playground",
|
||||
@@ -105,6 +109,7 @@ export const entitlementAccess: Record<
|
||||
},
|
||||
"self-hosted:enterprise": {
|
||||
entitlements: [
|
||||
...selfHostedAllPlansEntitlements,
|
||||
"annotation-queues",
|
||||
"model-based-evaluations",
|
||||
"playground",
|
||||
|
||||
@@ -5,84 +5,26 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogDescription,
|
||||
} from "@/src/components/ui/dialog";
|
||||
import * as z from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/src/components/ui/form";
|
||||
import { useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Textarea } from "@/src/components/ui/textarea";
|
||||
import Link from "next/link";
|
||||
import { Bug, LifeBuoy, Sparkles } from "lucide-react";
|
||||
|
||||
interface FeedbackDialogProps {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
type: "feedback" | "dashboard";
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
const formSchema = z.object({
|
||||
feedback: z.string().min(3, "Must have at least 3 characters"),
|
||||
});
|
||||
|
||||
export function FeedbackButtonWrapper({
|
||||
className,
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
type,
|
||||
description = "What do you think about Langfuse? What can be improved? Please share it with the community on GitHub to shape the future of Langfuse.",
|
||||
title = "Provide Feedback",
|
||||
}: FeedbackDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const session = useSession();
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
feedback: "",
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
try {
|
||||
const res = await fetch("https://cloud.langfuse.com/api/feedback", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type,
|
||||
...values,
|
||||
url: window.location.href,
|
||||
user: session.data?.user,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
form.reset();
|
||||
setOpen(false);
|
||||
} else {
|
||||
const data = res.json();
|
||||
console.error(data);
|
||||
form.setError("feedback", {
|
||||
type: "manual",
|
||||
message: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
form.setError("feedback", {
|
||||
type: "manual",
|
||||
message:
|
||||
"Failed to submit feedback, please email us: founders@langfuse.com",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
@@ -92,35 +34,25 @@ export function FeedbackButtonWrapper({
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="feedback"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormDescription>{description}</FormDescription>
|
||||
<FormControl>
|
||||
<Textarea {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={form.formState.isSubmitting}
|
||||
className="w-full"
|
||||
>
|
||||
{form.formState.isSubmitting ? "Loading ..." : "Submit"}
|
||||
<div className="flex flex-row flex-wrap items-center justify-center gap-3 sm:justify-start">
|
||||
<Link href="https://langfuse.com/ideas" target="_blank">
|
||||
<Button variant="secondary">
|
||||
<Sparkles className="mr-2 h-4 w-4" /> Submit Feature Request
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</Link>
|
||||
<Link href="https://langfuse.com/issues" target="_blank">
|
||||
<Button variant="secondary">
|
||||
<Bug className="mr-2 h-4 w-4" /> Report a Bug
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/support" target="_blank">
|
||||
<Button variant="outline">
|
||||
<LifeBuoy className="mr-2 h-4 w-4" /> Support
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import { UpsertModelFormDrawer } from "@/src/features/models/components/UpsertModelFormDrawer";
|
||||
import { type GetModelResult } from "@/src/features/models/validation";
|
||||
|
||||
export const CloneModelButton = ({
|
||||
modelData,
|
||||
projectId,
|
||||
}: {
|
||||
modelData: GetModelResult;
|
||||
projectId: string;
|
||||
}) => {
|
||||
const hasAccess = useHasProjectAccess({
|
||||
projectId,
|
||||
scope: "models:CUD",
|
||||
});
|
||||
|
||||
return (
|
||||
<UpsertModelFormDrawer {...{ modelData, projectId, action: "clone" }}>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!hasAccess}
|
||||
title="Clone model"
|
||||
className="flex items-center"
|
||||
>
|
||||
<span>Clone</span>
|
||||
</Button>
|
||||
</UpsertModelFormDrawer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/src/components/ui/popover";
|
||||
import { type GetModelResult } from "@/src/features/models/validation";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import { api } from "@/src/utils/api";
|
||||
|
||||
export const DeleteModelButton = ({
|
||||
modelData,
|
||||
projectId,
|
||||
onSuccess,
|
||||
}: {
|
||||
modelData: GetModelResult;
|
||||
projectId: string;
|
||||
onSuccess?: () => void;
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
const capture = usePostHogClientCapture();
|
||||
const mut = api.models.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.models.invalidate();
|
||||
onSuccess?.();
|
||||
},
|
||||
});
|
||||
|
||||
const hasAccess = useHasProjectAccess({
|
||||
projectId,
|
||||
scope: "models:CUD",
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={() => setIsOpen(!isOpen)}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
title="Delete model"
|
||||
disabled={!hasAccess}
|
||||
className="flex items-center border-light-red"
|
||||
>
|
||||
<span className="text-dark-red">Delete</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<h2 className="text-md mb-3 font-semibold">Please confirm</h2>
|
||||
<p className="mb-3 text-sm">
|
||||
This action permanently deletes this model definition.
|
||||
</p>
|
||||
<div className="flex justify-end space-x-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
loading={mut.isLoading}
|
||||
onClick={() => {
|
||||
capture("models:delete_button_click");
|
||||
mut.mutateAsync({
|
||||
projectId,
|
||||
modelId: modelData.id,
|
||||
});
|
||||
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Delete Model
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import { UpsertModelFormDrawer } from "@/src/features/models/components/UpsertModelFormDrawer";
|
||||
import { type GetModelResult } from "@/src/features/models/validation";
|
||||
|
||||
export const EditModelButton = ({
|
||||
modelData,
|
||||
projectId,
|
||||
}: {
|
||||
modelData: GetModelResult;
|
||||
projectId: string;
|
||||
}) => {
|
||||
const hasAccess = useHasProjectAccess({
|
||||
projectId,
|
||||
scope: "models:CUD",
|
||||
});
|
||||
|
||||
return (
|
||||
<UpsertModelFormDrawer {...{ modelData, projectId, action: "edit" }}>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!hasAccess}
|
||||
title="Edit model"
|
||||
className="flex items-center"
|
||||
>
|
||||
<span>Edit</span>
|
||||
</Button>
|
||||
</UpsertModelFormDrawer>
|
||||
);
|
||||
};
|
||||
@@ -1,456 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
|
||||
import { DatePicker } from "@/src/components/date-picker";
|
||||
import Header from "@/src/components/layouts/header";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/src/components/ui/form";
|
||||
import { Input } from "@/src/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/src/components/ui/select";
|
||||
import { ModelUsageUnit } from "@langfuse/shared";
|
||||
import { AutoComplete } from "@/src/features/prompts/components/auto-complete";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { JsonEditor } from "@/src/components/json-editor";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import Link from "next/link";
|
||||
import { utcDate } from "@/src/utils/dates";
|
||||
|
||||
const formSchema = z.object({
|
||||
modelName: z.string().min(1),
|
||||
matchPattern: z.string(),
|
||||
startDate: z.date().optional(),
|
||||
inputPrice: z
|
||||
.string()
|
||||
.refine((value) => value === "" || isFinite(parseFloat(value)), {
|
||||
message: "Price needs to be numeric",
|
||||
})
|
||||
.optional(),
|
||||
outputPrice: z
|
||||
.string()
|
||||
.refine((value) => value === "" || isFinite(parseFloat(value)), {
|
||||
message: "Price needs to be numeric",
|
||||
})
|
||||
.optional(),
|
||||
totalPrice: z
|
||||
.string()
|
||||
.refine((value) => value === "" || isFinite(parseFloat(value)), {
|
||||
message: "Price needs to be numeric",
|
||||
})
|
||||
.optional(),
|
||||
unit: z.nativeEnum(ModelUsageUnit),
|
||||
tokenizerId: z.enum(["openai", "claude", "None"]),
|
||||
tokenizerConfig: z.string().refine(
|
||||
(value) => {
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{
|
||||
message: "Tokenizer config needs to be valid JSON",
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
export const NewModelForm = (props: {
|
||||
projectId: string;
|
||||
onFormSuccess?: () => void;
|
||||
}) => {
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const capture = usePostHogClientCapture();
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
modelName: "",
|
||||
matchPattern: "",
|
||||
startDate: undefined,
|
||||
inputPrice: "",
|
||||
outputPrice: "",
|
||||
totalPrice: "",
|
||||
unit: ModelUsageUnit.Tokens,
|
||||
tokenizerId: "None",
|
||||
tokenizerConfig: "{}",
|
||||
},
|
||||
});
|
||||
|
||||
const utils = api.useUtils();
|
||||
const createModelMutation = api.models.create.useMutation({
|
||||
onSuccess: () => utils.models.invalidate(),
|
||||
onError: (error) => setFormError(error.message),
|
||||
});
|
||||
|
||||
const modelNames = api.models.modelNames.useQuery({
|
||||
projectId: props.projectId,
|
||||
});
|
||||
|
||||
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
capture("models:new_form_submit");
|
||||
createModelMutation
|
||||
.mutateAsync({
|
||||
projectId: props.projectId,
|
||||
modelName: values.modelName,
|
||||
matchPattern: values.matchPattern,
|
||||
inputPrice: !!values.inputPrice
|
||||
? parseFloat(values.inputPrice)
|
||||
: undefined,
|
||||
outputPrice: !!values.outputPrice
|
||||
? parseFloat(values.outputPrice)
|
||||
: undefined,
|
||||
totalPrice: !!values.totalPrice
|
||||
? parseFloat(values.totalPrice)
|
||||
: undefined,
|
||||
unit: values.unit,
|
||||
tokenizerId:
|
||||
values.tokenizerId === "None" ? undefined : values.tokenizerId,
|
||||
tokenizerConfig:
|
||||
values.tokenizerConfig &&
|
||||
typeof JSON.parse(values.tokenizerConfig) === "object"
|
||||
? (JSON.parse(values.tokenizerConfig) as Record<string, number>)
|
||||
: undefined,
|
||||
startDate: values.startDate ? utcDate(values.startDate) : undefined,
|
||||
})
|
||||
.then(() => {
|
||||
props.onFormSuccess?.();
|
||||
form.reset();
|
||||
})
|
||||
.catch((error) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
if ("message" in error && typeof error.message === "string") {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
setFormError(error.message as string);
|
||||
return;
|
||||
} else {
|
||||
setFormError(JSON.stringify(error));
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<Header level="h3" title="Name" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="modelName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Model Name</FormLabel>
|
||||
<FormControl>
|
||||
<AutoComplete
|
||||
{...field}
|
||||
options={
|
||||
modelNames.data?.map((model) => ({
|
||||
value: model,
|
||||
label: model,
|
||||
})) ?? []
|
||||
}
|
||||
placeholder=""
|
||||
onValueChange={(option) => field.onChange(option.value)}
|
||||
value={{ value: field.value, label: field.value }}
|
||||
disabled={false}
|
||||
createLabel="Create a new model name"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The name of the model. This will be used to reference the model
|
||||
in the API. You can track price changes of models by using the
|
||||
same name and match pattern.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Header level="h3" title="Scope" className="mt-3" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="matchPattern"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Match pattern</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Regular expression (Postgres syntax) to match ingested
|
||||
generations (model attribute) to this model definition. For an
|
||||
exact, case-insensitive match to a model name, use the
|
||||
expression: (?i)^modelname$
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="startDate"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Start date (UTC)</FormLabel>
|
||||
<FormControl>
|
||||
<DatePicker
|
||||
date={field.value}
|
||||
onChange={(date) => field.onChange(date)}
|
||||
clearable
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
If set, the model will only be used for generations after this
|
||||
date.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Header level="h3" title="Pricing" className="mt-3" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="unit"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Unit</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a unit" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{Object.values(ModelUsageUnit).map((unit) => (
|
||||
<SelectItem value={unit} key={unit}>
|
||||
{unit}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
The unit of measurement for the model.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="inputPrice"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Input price (USD per{" "}
|
||||
{form.getValues("unit").toLowerCase().replace(/s$/, "")})
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="number" />
|
||||
</FormControl>
|
||||
{field.value !== null && field.value !== "" ? (
|
||||
<FormDescription>
|
||||
<ul className="font-mono text-xs">
|
||||
<li>
|
||||
{(parseFloat(field.value ?? "0") * 1000).toFixed(8)} USD
|
||||
/ 1k {form.getValues("unit").toLowerCase()}
|
||||
</li>
|
||||
<li>
|
||||
{(parseFloat(field.value ?? "0") * 100_000).toFixed(8)}{" "}
|
||||
USD / 100k {form.getValues("unit").toLowerCase()}
|
||||
</li>
|
||||
<li>
|
||||
{(parseFloat(field.value ?? "0") * 1_000_000).toFixed(
|
||||
8,
|
||||
)}{" "}
|
||||
USD / 1M {form.getValues("unit").toLowerCase()}
|
||||
</li>
|
||||
</ul>
|
||||
</FormDescription>
|
||||
) : null}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="outputPrice"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Output price (USD per{" "}
|
||||
{form.getValues("unit").toLowerCase().replace(/s$/, "")})
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="number" />
|
||||
</FormControl>
|
||||
{field.value !== null && field.value !== "" ? (
|
||||
<FormDescription>
|
||||
<ul className="font-mono text-xs">
|
||||
<li>
|
||||
{(parseFloat(field.value ?? "0") * 1000).toFixed(8)} USD
|
||||
/ 1k {form.getValues("unit").toLowerCase()}
|
||||
</li>
|
||||
<li>
|
||||
{(parseFloat(field.value ?? "0") * 100_000).toFixed(8)}{" "}
|
||||
USD / 100k {form.getValues("unit").toLowerCase()}
|
||||
</li>
|
||||
<li>
|
||||
{(parseFloat(field.value ?? "0") * 1_000_000).toFixed(
|
||||
8,
|
||||
)}{" "}
|
||||
USD / 1M {form.getValues("unit").toLowerCase()}
|
||||
</li>
|
||||
</ul>
|
||||
</FormDescription>
|
||||
) : null}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="totalPrice"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Total price (USD per{" "}
|
||||
{form.getValues("unit").toLowerCase().replace(/s$/, "")})
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="number" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{field.value !== null && field.value !== "" ? (
|
||||
<ul className="mt-2 font-mono text-xs">
|
||||
<li>
|
||||
{(parseFloat(field.value ?? "0") * 1000).toFixed(8)} USD
|
||||
/ 1k {form.getValues("unit").toLowerCase()}
|
||||
</li>
|
||||
<li>
|
||||
{(parseFloat(field.value ?? "0") * 100_000).toFixed(8)}{" "}
|
||||
USD / 100k {form.getValues("unit").toLowerCase()}
|
||||
</li>
|
||||
<li>
|
||||
{(parseFloat(field.value ?? "0") * 1_000_000).toFixed(
|
||||
8,
|
||||
)}{" "}
|
||||
USD / 1M {form.getValues("unit").toLowerCase()}
|
||||
</li>
|
||||
</ul>
|
||||
) : (
|
||||
"Enter total price only if no separate input and output prices are provided."
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Header level="h3" title="Tokenization" className="mt-3" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tokenizerId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Tokenizer</FormLabel>
|
||||
<Select
|
||||
onValueChange={(tokenizerId) => {
|
||||
field.onChange(tokenizerId);
|
||||
if (tokenizerId === "None") {
|
||||
form.setValue("tokenizerConfig", "{}");
|
||||
}
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a unit" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{["openai", "claude", "None"].map((unit) => (
|
||||
<SelectItem value={unit} key={unit}>
|
||||
{unit}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Optionally, Langfuse can tokenize the input and output of a
|
||||
generation if no unit counts are ingested. This is useful for
|
||||
e.g. streamed OpenAI completions. For details on the supported
|
||||
tokenizers, see the{" "}
|
||||
<Link
|
||||
href="https://langfuse.com/docs/model-usage-and-cost"
|
||||
className="underline"
|
||||
target="_blank"
|
||||
>
|
||||
docs
|
||||
</Link>
|
||||
.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{form.watch("tokenizerId") !== "None" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tokenizerConfig"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Tokenizer Config</FormLabel>
|
||||
<JsonEditor
|
||||
defaultValue={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
<FormDescription>
|
||||
The config for the tokenizer. Required for openai. See the{" "}
|
||||
<Link
|
||||
href="https://langfuse.com/docs/model-usage-and-cost"
|
||||
className="underline"
|
||||
target="_blank"
|
||||
>
|
||||
docs
|
||||
</Link>{" "}
|
||||
for details.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
loading={createModelMutation.isLoading}
|
||||
className="mt-6"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
{formError ? (
|
||||
<p className="text-red text-center">
|
||||
<span className="font-bold">Error:</span> {formError}
|
||||
</p>
|
||||
) : null}
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
import Decimal from "decimal.js";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { type RowHeight } from "@/src/components/table/data-table-row-height-switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/src/components/ui/tooltip";
|
||||
import { usePriceUnitMultiplier } from "@/src/features/models/hooks/usePriceUnitMultiplier";
|
||||
import { getMaxDecimals } from "@/src/features/models/utils";
|
||||
import { type PriceUnit } from "@/src/features/models/validation";
|
||||
|
||||
export const PriceBreakdownTooltip = ({
|
||||
modelName,
|
||||
prices,
|
||||
priceUnit,
|
||||
rowHeight,
|
||||
}: {
|
||||
modelName: string;
|
||||
prices?: Record<string, number>;
|
||||
priceUnit: PriceUnit;
|
||||
rowHeight: RowHeight;
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { priceUnitMultiplier } = usePriceUnitMultiplier();
|
||||
|
||||
const maxDecimals = useMemo(
|
||||
() =>
|
||||
Math.max(
|
||||
...Object.values(prices ?? {}).map((price) => {
|
||||
return getMaxDecimals(price, priceUnitMultiplier);
|
||||
}),
|
||||
),
|
||||
[prices, priceUnitMultiplier],
|
||||
);
|
||||
|
||||
if (!prices) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{Object.keys(prices).length === 0 ? (
|
||||
<p>No prices</p>
|
||||
) : Object.keys(prices).length <= (rowHeight === "m" ? 4 : 2) ? (
|
||||
<div className="grid w-full grid-cols-[2fr,3fr] gap-x-2">
|
||||
{Object.entries(prices).map(([type, price]) => (
|
||||
<>
|
||||
<span
|
||||
key={`${type}-label`}
|
||||
className="truncate font-mono text-xs font-medium"
|
||||
title={type}
|
||||
>
|
||||
{type}
|
||||
</span>
|
||||
<span
|
||||
key={`${type}-price`}
|
||||
className="text-left font-mono text-xs font-medium tabular-nums"
|
||||
>
|
||||
$
|
||||
{new Decimal(price)
|
||||
.mul(priceUnitMultiplier)
|
||||
.toFixed(maxDecimals)}
|
||||
</span>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<Tooltip open={isOpen} onOpenChange={setIsOpen}>
|
||||
<TooltipTrigger
|
||||
className="flex cursor-pointer items-center gap-2 pr-[1rem] text-xs"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<InfoIcon className="h-3 w-3" />
|
||||
{Object.keys(prices).length} prices set
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="min-w-[16rem] grow p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-semibold">Price breakdown</span>
|
||||
<span className="font-mono text-xs font-medium">
|
||||
{modelName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex justify-between font-mono text-xs font-semibold">
|
||||
<span className="mr-4">Usage Type</span>
|
||||
<span>Price {priceUnit}</span>
|
||||
</div>
|
||||
{Object.entries(prices).map(([usageType, price]) => (
|
||||
<div
|
||||
key={usageType}
|
||||
className="flex justify-between font-mono text-xs"
|
||||
>
|
||||
<span className="mr-4">{usageType}</span>
|
||||
<span>
|
||||
{"$" +
|
||||
new Decimal(price)
|
||||
.mul(priceUnitMultiplier)
|
||||
.toFixed(maxDecimals)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import Decimal from "decimal.js";
|
||||
|
||||
import { PriceMapSchema } from "@/src/features/models/validation";
|
||||
import { getMaxDecimals } from "@/src/features/models/utils";
|
||||
|
||||
export function PricePreview({
|
||||
prices,
|
||||
}: {
|
||||
prices: Record<string, number | undefined>;
|
||||
}) {
|
||||
const parsedPrices = PriceMapSchema.safeParse(prices);
|
||||
|
||||
const getMaxDecimalsForPriceGroup = (
|
||||
price: number | undefined,
|
||||
multiplier: number,
|
||||
) => {
|
||||
return price != null
|
||||
? Math.max(
|
||||
...Object.values(prices).map((price) => {
|
||||
return getMaxDecimals(price, multiplier);
|
||||
}),
|
||||
)
|
||||
: 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-muted/30 p-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">
|
||||
Price Preview
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
{parsedPrices.success ? (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-[2fr_1fr_1fr_1fr] gap-2 border-b border-border pb-2 text-xs font-medium text-muted-foreground">
|
||||
<span>Usage Type</span>
|
||||
<span className="text-right">per unit</span>
|
||||
<span className="text-right">per 1K</span>
|
||||
<span className="text-right">per 1M</span>
|
||||
</div>
|
||||
|
||||
{Object.entries(parsedPrices.data)
|
||||
.filter((entry): entry is [string, number] => Boolean(entry[1]))
|
||||
.map(([usageType, price]) => (
|
||||
<div
|
||||
key={usageType}
|
||||
className="grid grid-cols-[2fr_1fr_1fr_1fr] gap-2 rounded px-1 py-0.5 text-xs text-muted-foreground"
|
||||
>
|
||||
<span className="break-all font-medium">{usageType}</span>
|
||||
<span className="text-right font-mono">
|
||||
$
|
||||
{new Decimal(price).toFixed(
|
||||
getMaxDecimalsForPriceGroup(price, 1),
|
||||
)}
|
||||
</span>
|
||||
<span className="text-right font-mono">
|
||||
$
|
||||
{new Decimal(price)
|
||||
.mul(1000)
|
||||
.toFixed(getMaxDecimalsForPriceGroup(price, 1000))}
|
||||
</span>
|
||||
<span className="text-right font-mono">
|
||||
$
|
||||
{new Decimal(price)
|
||||
.mul(1000000)
|
||||
.toFixed(getMaxDecimalsForPriceGroup(price, 1000000))}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
Invalid price entries. Please check your input format.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/src/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/src/components/ui/select";
|
||||
import { PriceUnit } from "@/src/features/models/validation";
|
||||
import { usePriceUnitMultiplier } from "@/src/features/models/hooks/usePriceUnitMultiplier";
|
||||
|
||||
export const PriceUnitSelector = () => {
|
||||
const { priceUnit, setPriceUnit } = usePriceUnitMultiplier();
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button size="icon" variant="ghost">
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0">
|
||||
<Select
|
||||
value={priceUnit}
|
||||
onValueChange={(value: PriceUnit) => setPriceUnit(value)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select unit" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.values(PriceUnit).map((unit) => (
|
||||
<SelectItem key={unit} value={unit}>
|
||||
{unit}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,486 @@
|
||||
import { MinusCircle, PlusCircle, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
|
||||
import { JsonEditor } from "@/src/components/json-editor";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/src/components/ui/drawer";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/src/components/ui/form";
|
||||
import { Input } from "@/src/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/src/components/ui/select";
|
||||
import {
|
||||
type FormUpsertModel,
|
||||
FormUpsertModelSchema,
|
||||
type GetModelResult,
|
||||
} from "@/src/features/models/validation";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { PricePreview } from "./PricePreview";
|
||||
import { showSuccessToast } from "@/src/features/notifications/showSuccessToast";
|
||||
|
||||
type UpsertModelDrawerProps =
|
||||
| {
|
||||
action: "create";
|
||||
children: React.ReactNode;
|
||||
projectId: string;
|
||||
prefilledModelData?: {
|
||||
modelName?: string;
|
||||
prices?: Record<string, number>;
|
||||
};
|
||||
className?: string;
|
||||
}
|
||||
| {
|
||||
action: "edit" | "clone";
|
||||
children: React.ReactNode;
|
||||
projectId: string;
|
||||
modelData: GetModelResult;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const UpsertModelFormDrawer = ({
|
||||
children,
|
||||
...props
|
||||
}: UpsertModelDrawerProps) => {
|
||||
const capture = usePostHogClientCapture();
|
||||
const router = useRouter();
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const utils = api.useUtils();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
let defaultValues: FormUpsertModel;
|
||||
if (props.action !== "create") {
|
||||
defaultValues = {
|
||||
modelName: props.modelData.modelName,
|
||||
matchPattern: props.modelData.matchPattern,
|
||||
tokenizerId: props.modelData.tokenizerId,
|
||||
tokenizerConfig: JSON.stringify(props.modelData.tokenizerConfig ?? {}),
|
||||
prices: props.modelData.prices,
|
||||
};
|
||||
} else {
|
||||
defaultValues = {
|
||||
modelName: props.prefilledModelData?.modelName ?? "",
|
||||
matchPattern: props.prefilledModelData?.modelName
|
||||
? `(?i)^(${props.prefilledModelData?.modelName})$`
|
||||
: "",
|
||||
tokenizerId: null,
|
||||
tokenizerConfig: null,
|
||||
prices: props.prefilledModelData?.prices ?? {
|
||||
input: 0.000001,
|
||||
output: 0.000002,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const form = useForm<FormUpsertModel>({
|
||||
resolver: zodResolver(
|
||||
props.action === "edit"
|
||||
? FormUpsertModelSchema.omit({ modelName: true }).extend({
|
||||
modelName: z.string().default(props.modelData.modelName),
|
||||
})
|
||||
: FormUpsertModelSchema,
|
||||
),
|
||||
defaultValues,
|
||||
});
|
||||
const modelName = form.watch("modelName");
|
||||
const matchPattern = form.watch("matchPattern");
|
||||
const tokenizerId = form.watch("tokenizerId");
|
||||
|
||||
// prefill match pattern if model name changes
|
||||
useEffect(() => {
|
||||
const getRegexString = (modelName: string) => `(?i)^(${modelName})$`;
|
||||
|
||||
if (
|
||||
modelName &&
|
||||
(!matchPattern ||
|
||||
matchPattern === `(?i)^(${modelName.slice(0, -1)})$` ||
|
||||
matchPattern === `(?i)^(${modelName})$`)
|
||||
) {
|
||||
form.setValue("matchPattern", getRegexString(modelName));
|
||||
}
|
||||
}, [modelName, matchPattern, form]);
|
||||
|
||||
const upsertModelMutation = api.models.upsert.useMutation({
|
||||
onSuccess: (upsertedModel) => {
|
||||
utils.models.invalidate();
|
||||
form.reset();
|
||||
setOpen(false);
|
||||
showSuccessToast({
|
||||
title: `Model ${props.action === "edit" ? "updated" : "created"}`,
|
||||
description: `The model '${upsertedModel.modelName}' has been successfully ${props.action === "edit" ? "updated" : "created"}. New generations will use these model prices.`,
|
||||
});
|
||||
router.push(`/project/${props.projectId}/models/${upsertedModel.id}`);
|
||||
},
|
||||
onError: (error) => setFormError(error.message),
|
||||
});
|
||||
|
||||
const onSubmit = async (values: FormUpsertModel) => {
|
||||
capture("models:new_form_submit");
|
||||
|
||||
await upsertModelMutation
|
||||
.mutateAsync({
|
||||
modelId: props.action === "edit" ? props.modelData.id : null,
|
||||
projectId: props.projectId,
|
||||
modelName: values.modelName,
|
||||
matchPattern: values.matchPattern,
|
||||
prices: values.prices,
|
||||
tokenizerId: values.tokenizerId,
|
||||
tokenizerConfig:
|
||||
values.tokenizerConfig &&
|
||||
typeof JSON.parse(values.tokenizerConfig) === "object"
|
||||
? (JSON.parse(values.tokenizerConfig) as Record<string, number>)
|
||||
: undefined,
|
||||
})
|
||||
.catch((error) => {
|
||||
setFormError(error.message);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) return; // Only allow closing via cancel key
|
||||
setOpen(open);
|
||||
}}
|
||||
dismissible={false}
|
||||
onClose={() => {
|
||||
form.reset();
|
||||
setFormError(null);
|
||||
}}
|
||||
>
|
||||
<DrawerTrigger
|
||||
asChild
|
||||
onClick={() => setOpen(true)}
|
||||
className={props.className}
|
||||
title={
|
||||
props.action === "create"
|
||||
? "Create model definition"
|
||||
: "Edit model definition"
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<DrawerTitle>
|
||||
{props.action === "create"
|
||||
? "Create Model"
|
||||
: props.action === "clone"
|
||||
? "Clone Model"
|
||||
: "Edit Model"}
|
||||
</DrawerTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setOpen(false)}
|
||||
type="button"
|
||||
>
|
||||
<X size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<DrawerDescription>
|
||||
{props.action === "edit"
|
||||
? props.modelData.modelName
|
||||
: props.action === "create"
|
||||
? "Create a new model configuration to track generation costs."
|
||||
: null}
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex h-full max-h-[100vh] flex-col gap-6 overflow-y-auto p-4 pt-0"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="modelName"
|
||||
disabled={props.action === "edit"}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Model Name</FormLabel>
|
||||
<FormDescription>
|
||||
The name of the model. This will be used to reference the
|
||||
model in the API. You can track price changes of models by
|
||||
using the same name and match pattern.
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="matchPattern"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Match pattern</FormLabel>
|
||||
<FormDescription>
|
||||
Regular expression (Postgres syntax) to match ingested
|
||||
generations (model attribute) to this model definition. For
|
||||
an exact, case-insensitive match to a model name, use the
|
||||
expression: (?i)^(modelname)$
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="prices"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
Prices
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Set prices per usage type for this model. Usage types must
|
||||
exactly match the keys of the ingested usage details.
|
||||
</FormDescription>
|
||||
<span className="flex flex-col gap-2">
|
||||
<FormDescription>
|
||||
Prefill usage types from template:
|
||||
</FormDescription>
|
||||
<span className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
field.onChange({
|
||||
input: 0,
|
||||
output: 0,
|
||||
input_cached_tokens: 0,
|
||||
output_reasoning_tokens: 0,
|
||||
...field.value,
|
||||
});
|
||||
}}
|
||||
>
|
||||
OpenAI
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
field.onChange({
|
||||
input: 0,
|
||||
input_tokens: 0,
|
||||
output: 0,
|
||||
output_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
...field.value,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Anthropic
|
||||
</Button>
|
||||
</span>
|
||||
</span>
|
||||
<FormControl>
|
||||
<span className="flex flex-col gap-2">
|
||||
<FormDescription className="grid grid-cols-2 gap-1">
|
||||
<span>Usage type</span>
|
||||
<span>Price</span>
|
||||
</FormDescription>
|
||||
{Object.entries(field.value).map(
|
||||
([key, value], index) => (
|
||||
<div key={index} className="grid grid-cols-2 gap-1">
|
||||
<Input
|
||||
placeholder="Key (e.g. input, output)"
|
||||
value={key}
|
||||
onChange={(e) => {
|
||||
const newPrices = { ...field.value };
|
||||
const oldValue = newPrices[key];
|
||||
delete newPrices[key];
|
||||
newPrices[e.target.value] = oldValue;
|
||||
field.onChange(newPrices);
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Price per unit"
|
||||
value={value}
|
||||
step="0.000001"
|
||||
onChange={(e) => {
|
||||
field.onChange({
|
||||
...field.value,
|
||||
[key]: parseFloat(e.target.value),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
title="Remove price"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
const newPrices = { ...field.value };
|
||||
delete newPrices[key];
|
||||
field.onChange(newPrices);
|
||||
}}
|
||||
>
|
||||
<MinusCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
field.onChange({
|
||||
...field.value,
|
||||
new_usage_type: 0.000001,
|
||||
});
|
||||
}}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<PlusCircle className="h-4 w-4" />
|
||||
<span>Add Price</span>
|
||||
</Button>
|
||||
<PricePreview prices={field.value} />
|
||||
</span>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tokenizerId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Tokenizer</FormLabel>
|
||||
<Select
|
||||
onValueChange={(tokenizerId) => {
|
||||
field.onChange(tokenizerId);
|
||||
if (tokenizerId === "None") {
|
||||
form.setValue("tokenizerConfig", "{}");
|
||||
}
|
||||
}}
|
||||
defaultValue={field.value ?? "None"}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a unit" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{["openai", "claude", "None"].map((unit) => (
|
||||
<SelectItem value={unit} key={unit}>
|
||||
{unit}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Optionally, Langfuse can tokenize the input and output of a
|
||||
generation if no unit counts are ingested. This is useful
|
||||
for e.g. streamed OpenAI completions. For details on the
|
||||
supported tokenizers, see the{" "}
|
||||
<Link
|
||||
href="https://langfuse.com/docs/model-usage-and-cost"
|
||||
className="underline"
|
||||
target="_blank"
|
||||
>
|
||||
docs
|
||||
</Link>
|
||||
.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{tokenizerId && tokenizerId !== "None" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tokenizerConfig"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Tokenizer Config</FormLabel>
|
||||
<JsonEditor
|
||||
defaultValue={field.value ?? "{}"}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
<FormDescription>
|
||||
The config for the tokenizer. Required for openai. See the{" "}
|
||||
<Link
|
||||
href="https://langfuse.com/docs/model-usage-and-cost"
|
||||
className="underline"
|
||||
target="_blank"
|
||||
>
|
||||
docs
|
||||
</Link>{" "}
|
||||
for details.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<DrawerFooter className="flex-row gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
className="w-full"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
loading={upsertModelMutation.isLoading}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</DrawerFooter>
|
||||
</form>
|
||||
{formError ? (
|
||||
<p className="my-2 text-center text-sm font-medium text-destructive">
|
||||
<span className="font-semibold">Error:</span> {formError}
|
||||
</p>
|
||||
) : null}
|
||||
</Form>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import useLocalStorage from "@/src/components/useLocalStorage";
|
||||
import { PriceUnit } from "@/src/features/models/validation";
|
||||
|
||||
export const multiplierMap: Record<PriceUnit, number> = {
|
||||
[PriceUnit.PerUnit]: 1,
|
||||
[PriceUnit.Per1KUnits]: 1e3,
|
||||
[PriceUnit.Per1MUnits]: 1e6,
|
||||
};
|
||||
|
||||
export const usePriceUnitMultiplier = () => {
|
||||
const [priceUnit, setPriceUnit] = useLocalStorage<PriceUnit>(
|
||||
"priceUnit",
|
||||
PriceUnit.PerUnit,
|
||||
);
|
||||
const multiplier = useMemo(() => multiplierMap[priceUnit], [priceUnit]);
|
||||
|
||||
return { priceUnit, setPriceUnit, priceUnitMultiplier: multiplier };
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import Decimal from "decimal.js";
|
||||
|
||||
export const getMaxDecimals = (
|
||||
value: number | undefined,
|
||||
scaleMultiplier: number = 1,
|
||||
) => {
|
||||
return (
|
||||
new Decimal(value ?? 0)
|
||||
.mul(scaleMultiplier)
|
||||
.toFixed(12)
|
||||
.split(".")[1]
|
||||
?.replace(/0+$/, "").length ?? 0
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const UsageTypeSchema = z.string().regex(/^[a-zA-Z0-9_-]+$/);
|
||||
export const PriceSchema = z.number().nonnegative();
|
||||
export const TokenizerSchema = z.enum(["openai", "claude"]).nullish();
|
||||
export const PriceMapSchema = z
|
||||
.record(UsageTypeSchema, PriceSchema.optional())
|
||||
.transform((obj) => {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).filter(([_, value]) => Boolean(value)),
|
||||
);
|
||||
})
|
||||
.pipe(z.record(UsageTypeSchema, PriceSchema));
|
||||
|
||||
export const GetModelResultSchema = z.object({
|
||||
id: z.string(),
|
||||
projectId: z.string().nullable(),
|
||||
modelName: z.string(),
|
||||
matchPattern: z.string(),
|
||||
tokenizerConfig: z
|
||||
.record(z.union([z.string(), z.coerce.number()]))
|
||||
.nullable(),
|
||||
tokenizerId: TokenizerSchema,
|
||||
prices: PriceMapSchema,
|
||||
});
|
||||
|
||||
export type GetModelResult = z.infer<typeof GetModelResultSchema>;
|
||||
|
||||
export const UpsertModelSchema = z.object({
|
||||
modelId: z.string().nullable(),
|
||||
projectId: z.string(),
|
||||
modelName: z.string().min(1),
|
||||
matchPattern: z.string().min(1),
|
||||
tokenizerId: z
|
||||
.enum(["openai", "claude", "None"])
|
||||
.nullish()
|
||||
.transform((value) => {
|
||||
return value === "None" ? null : value;
|
||||
})
|
||||
.pipe(TokenizerSchema.nullish()),
|
||||
tokenizerConfig: z
|
||||
.record(z.union([z.string(), z.coerce.number()]))
|
||||
.optional(),
|
||||
prices: PriceMapSchema,
|
||||
});
|
||||
export type UpsertModel = z.infer<typeof UpsertModelSchema>;
|
||||
|
||||
export const FormUpsertModelSchema = z.object({
|
||||
modelName: z.string().min(1),
|
||||
matchPattern: z.string().min(1),
|
||||
tokenizerId: z.enum(["openai", "claude", "None"]).nullish(),
|
||||
tokenizerConfig: z
|
||||
.string()
|
||||
.refine(
|
||||
(value) => {
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{
|
||||
message: "Tokenizer config needs to be valid JSON",
|
||||
},
|
||||
)
|
||||
.transform((value) => (value === "{}" ? undefined : value))
|
||||
.nullish(),
|
||||
prices: PriceMapSchema,
|
||||
});
|
||||
export type FormUpsertModel = z.infer<typeof FormUpsertModelSchema>;
|
||||
|
||||
export enum PriceUnit {
|
||||
PerUnit = "per unit",
|
||||
Per1KUnits = "per 1K units",
|
||||
Per1MUnits = "per 1M units",
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ActionButton } from "@/src/components/ActionButton";
|
||||
import { BadgeCheck, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export type SuccessNotificationProps = {
|
||||
title: string;
|
||||
@@ -28,14 +28,19 @@ export const SuccessNotification: React.FC<SuccessNotificationProps> = ({
|
||||
</div>
|
||||
{description && (
|
||||
<div className="text-sm leading-tight text-primary-foreground">
|
||||
{description}{" "}
|
||||
{!!link && (
|
||||
<Link href={link.href}>
|
||||
<span className="hover:underline">{link.text}</span>
|
||||
</Link>
|
||||
)}
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
{link && (
|
||||
<ActionButton
|
||||
href={link.href}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="self-start"
|
||||
>
|
||||
{link.text}
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="flex h-6 w-6 cursor-pointer items-start justify-end border-none bg-transparent p-0 text-primary-foreground transition-colors duration-200"
|
||||
|
||||
@@ -48,6 +48,7 @@ import { PRODUCTION_LABEL } from "@/src/features/prompts/constants";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import usePlaygroundCache from "@/src/ee/features/playground/page/hooks/usePlaygroundCache";
|
||||
import { useQueryParam } from "use-query-params";
|
||||
import { Switch } from "@/src/components/ui/switch";
|
||||
|
||||
type NewPromptFormProps = {
|
||||
initialPrompt?: Prompt | null;
|
||||
@@ -61,6 +62,7 @@ export const NewPromptForm: React.FC<NewPromptFormProps> = (props) => {
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const { playgroundCache } = usePlaygroundCache();
|
||||
const [initialMessages, setInitialMessages] = useState<unknown>([]);
|
||||
const [showJsonEditor, setShowJsonEditor] = useState(false);
|
||||
|
||||
const utils = api.useUtils();
|
||||
const capture = usePostHogClientCapture();
|
||||
@@ -87,7 +89,7 @@ export const NewPromptForm: React.FC<NewPromptFormProps> = (props) => {
|
||||
: "",
|
||||
name: initialPrompt?.name ?? "",
|
||||
config: JSON.stringify(initialPrompt?.config?.valueOf(), null, 2) || "{}",
|
||||
isActive: false,
|
||||
isActive: !Boolean(initialPrompt),
|
||||
};
|
||||
|
||||
const form = useForm<NewPromptFormSchemaType>({
|
||||
@@ -97,7 +99,6 @@ export const NewPromptForm: React.FC<NewPromptFormProps> = (props) => {
|
||||
|
||||
const currentName = form.watch("name");
|
||||
const currentType = form.watch("type");
|
||||
const currentIsActive = form.watch("isActive");
|
||||
const currentExtractedVariables = extractVariables(
|
||||
currentType === PromptType.Text
|
||||
? form.watch("textPrompt")
|
||||
@@ -250,7 +251,24 @@ export const NewPromptForm: React.FC<NewPromptFormProps> = (props) => {
|
||||
{/* Prompt content field - text vs. chat */}
|
||||
<>
|
||||
<FormItem>
|
||||
<FormLabel>Prompt</FormLabel>
|
||||
<FormLabel className="flex flex-row items-center justify-between">
|
||||
<div>Prompt</div>
|
||||
{form.watch("type") === PromptType.Text ? (
|
||||
<div className="flex flex-row items-center">
|
||||
<p className="mr-1 text-xs text-muted-foreground">
|
||||
JSON editor
|
||||
</p>
|
||||
|
||||
<Switch
|
||||
checked={showJsonEditor}
|
||||
className={
|
||||
showJsonEditor ? "data-[state=checked]:bg-dark-green" : ""
|
||||
}
|
||||
onCheckedChange={setShowJsonEditor}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</FormLabel>
|
||||
<Tabs
|
||||
value={form.watch("type")}
|
||||
onValueChange={(e) => {
|
||||
@@ -288,10 +306,18 @@ export const NewPromptForm: React.FC<NewPromptFormProps> = (props) => {
|
||||
render={({ field }) => (
|
||||
<>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
className="min-h-[200px] flex-1 font-mono text-xs"
|
||||
/>
|
||||
{showJsonEditor ? (
|
||||
<JsonEditor
|
||||
defaultValue={field.value}
|
||||
onChange={field.onChange}
|
||||
editable
|
||||
/>
|
||||
) : (
|
||||
<Textarea
|
||||
{...field}
|
||||
className="min-h-[200px] flex-1 font-mono text-xs"
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</>
|
||||
@@ -346,21 +372,23 @@ export const NewPromptForm: React.FC<NewPromptFormProps> = (props) => {
|
||||
control={form.control}
|
||||
name="isActive"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center space-x-3 space-y-0 rounded-md border p-3">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel>Serve prompt as default to SDKs</FormLabel>
|
||||
</div>
|
||||
{currentIsActive ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
This makes the prompt available to the SDKs immediately.
|
||||
<FormItem>
|
||||
<FormLabel>Labels</FormLabel>
|
||||
<div className="flex flex-row items-center space-x-3 space-y-0 rounded-md border p-3">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel>Set the "production" label</FormLabel>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<FormDescription>
|
||||
This version will be labeled as the version to be used in
|
||||
production for this prompt. Can be updated later.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from "@/src/server/api/trpc";
|
||||
import { type Prompt, Prisma } from "@langfuse/shared/src/db";
|
||||
import { createPrompt } from "../actions/createPrompt";
|
||||
import { observationsTableCols, type ScoreSimplified } from "@langfuse/shared";
|
||||
import { type ScoreSimplified } from "@langfuse/shared";
|
||||
import { promptsTableCols } from "@/src/server/api/definitions/promptsTable";
|
||||
import { optionalPaginationZod, paginationZod } from "@langfuse/shared";
|
||||
import { orderBy, singleFilter } from "@langfuse/shared";
|
||||
@@ -698,7 +698,6 @@ export const promptRouter = createTRPCRouter({
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
promptIds: z.array(z.string()),
|
||||
filter: z.array(singleFilter).nullish(),
|
||||
queryClickhouse: z.boolean().default(false),
|
||||
}),
|
||||
)
|
||||
@@ -715,11 +714,6 @@ export const promptRouter = createTRPCRouter({
|
||||
user: ctx.session.user,
|
||||
pgExecution: async () => {
|
||||
if (input.promptIds.length === 0) return [];
|
||||
const filterCondition = tableColumnsToSqlFilterAndPrefix(
|
||||
input.filter ?? [],
|
||||
observationsTableCols,
|
||||
"prompts",
|
||||
);
|
||||
const [metrics, generationScores, traceScores] = await Promise.all([
|
||||
// metrics
|
||||
ctx.prisma.$queryRaw<
|
||||
@@ -751,7 +745,6 @@ export const promptRouter = createTRPCRouter({
|
||||
o.prompt_id = p.id
|
||||
AND "type" = 'GENERATION'
|
||||
AND "project_id" = ${input.projectId}
|
||||
${filterCondition}
|
||||
) AS observation_metrics ON true
|
||||
WHERE "project_id" = ${input.projectId}
|
||||
AND p.id in (${Prisma.join(input.promptIds)})
|
||||
@@ -785,7 +778,6 @@ export const promptRouter = createTRPCRouter({
|
||||
AND o.project_id = ${input.projectId}
|
||||
AND s.name IS NOT NULL
|
||||
AND p.id IN (${Prisma.join(input.promptIds)})
|
||||
${filterCondition}
|
||||
) s ON TRUE
|
||||
WHERE
|
||||
p.project_id = ${input.projectId}
|
||||
@@ -820,7 +812,6 @@ export const promptRouter = createTRPCRouter({
|
||||
AND o.type = 'GENERATION'
|
||||
AND o.project_id = ${input.projectId}
|
||||
AND o.prompt_id IN (${Prisma.join(input.promptIds)})
|
||||
${filterCondition}
|
||||
)
|
||||
AND s.observation_id IS NULL
|
||||
AND s.project_id = ${input.projectId}
|
||||
|
||||
@@ -197,7 +197,7 @@ const getPlanBasedRateLimitConfig = (
|
||||
case "ingestion":
|
||||
return {
|
||||
resource: "ingestion",
|
||||
points: 2000,
|
||||
points: 4000,
|
||||
durationInSec: 60,
|
||||
};
|
||||
case "legacy-ingestion":
|
||||
@@ -233,7 +233,7 @@ const getPlanBasedRateLimitConfig = (
|
||||
case "ingestion":
|
||||
return {
|
||||
resource: "ingestion",
|
||||
points: 10000,
|
||||
points: 20000,
|
||||
durationInSec: 60,
|
||||
};
|
||||
case "legacy-ingestion":
|
||||
|
||||
@@ -34,7 +34,8 @@ const projectScopes = [
|
||||
|
||||
"models:CUD",
|
||||
|
||||
"batchExport:create",
|
||||
"batchExports:create",
|
||||
"batchExports:read",
|
||||
|
||||
"evalTemplate:create",
|
||||
"evalTemplate:read",
|
||||
@@ -85,7 +86,8 @@ export const projectRoleAccessRights: Record<Role, ProjectScope[]> = {
|
||||
"llmApiKeys:read",
|
||||
"llmApiKeys:create",
|
||||
"llmApiKeys:delete",
|
||||
"batchExport:create",
|
||||
"batchExports:create",
|
||||
"batchExports:read",
|
||||
"comments:CUD",
|
||||
"comments:read",
|
||||
"annotationQueues:read",
|
||||
@@ -120,7 +122,8 @@ export const projectRoleAccessRights: Record<Role, ProjectScope[]> = {
|
||||
"llmApiKeys:read",
|
||||
"llmApiKeys:create",
|
||||
"llmApiKeys:delete",
|
||||
"batchExport:create",
|
||||
"batchExports:create",
|
||||
"batchExports:read",
|
||||
"comments:CUD",
|
||||
"comments:read",
|
||||
"annotationQueues:read",
|
||||
@@ -147,7 +150,8 @@ export const projectRoleAccessRights: Record<Role, ProjectScope[]> = {
|
||||
"evalJob:CUD",
|
||||
"evalJobExecution:read",
|
||||
"llmApiKeys:read",
|
||||
"batchExport:create",
|
||||
"batchExports:create",
|
||||
"batchExports:read",
|
||||
"comments:CUD",
|
||||
"comments:read",
|
||||
"annotationQueues:read",
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
transformCategoricalScoresToChartData,
|
||||
uniqueAndSort,
|
||||
} from "@/src/features/dashboard/lib/score-analytics-utils";
|
||||
import {
|
||||
type ChartBin,
|
||||
type ChartData,
|
||||
type TimeseriesDataTransformer,
|
||||
} from "@/src/features/scores/types";
|
||||
import { type DatabaseRow } from "@/src/server/api/services/queryBuilder";
|
||||
import { type DashboardDateRangeAggregationOption } from "@/src/utils/date-range-utils";
|
||||
|
||||
export class DashboardCategoricalScoreAdapter
|
||||
implements TimeseriesDataTransformer
|
||||
{
|
||||
constructor(
|
||||
private data: DatabaseRow[],
|
||||
private timestamp: string,
|
||||
private agg?: DashboardDateRangeAggregationOption,
|
||||
) {}
|
||||
|
||||
toChartData(): ChartData {
|
||||
const { chartData, chartLabels } = transformCategoricalScoresToChartData(
|
||||
this.data,
|
||||
this.timestamp,
|
||||
this.agg,
|
||||
);
|
||||
return { chartData, chartLabels: uniqueAndSort(chartLabels) };
|
||||
}
|
||||
}
|
||||
|
||||
export class CompareViewAdapter implements TimeseriesDataTransformer {
|
||||
constructor(
|
||||
private runMetrics: Map<
|
||||
string,
|
||||
{ chartData: ChartBin[]; chartLabels: string[] }
|
||||
>,
|
||||
private key: string,
|
||||
) {}
|
||||
|
||||
toChartData(): ChartData {
|
||||
return {
|
||||
chartData: this.runMetrics.get(this.key)?.chartData ?? [],
|
||||
chartLabels: uniqueAndSort(
|
||||
this.runMetrics.get(this.key)?.chartLabels ?? [],
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -61,11 +61,11 @@ import { getDefaultScoreData } from "@/src/features/scores/lib/getDefaultScoreDa
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/src/components/ui/toggle-group";
|
||||
import Header from "@/src/components/layouts/header";
|
||||
import { MultiSelectKeyValues } from "@/src/features/scores/components/multi-select-key-values";
|
||||
import { CommandItem } from "@/src/components/ui/command";
|
||||
import { useRouter } from "next/router";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { getScoreDataTypeIcon } from "@/src/features/scores/components/ScoreDetailColumnHelpers";
|
||||
import { DropdownMenuItem } from "@/src/components/ui/dropdown-menu";
|
||||
|
||||
const AnnotationScoreDataSchema = z.object({
|
||||
name: z.string(),
|
||||
@@ -712,7 +712,7 @@ export function AnnotateDrawerContent({
|
||||
key: field.configId as string,
|
||||
}))}
|
||||
controlButtons={
|
||||
<CommandItem
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
capture("score_configs:manage_configs_item_click", {
|
||||
type: type,
|
||||
@@ -722,7 +722,7 @@ export function AnnotateDrawerContent({
|
||||
}}
|
||||
>
|
||||
Manage score configs
|
||||
</CommandItem>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { compactNumberFormatter } from "@/src/utils/numbers";
|
||||
import { getColorsForCategories } from "@/src/features/dashboard/utils/getColorsForCategories";
|
||||
import { isEmptyChart } from "@/src/features/dashboard/lib/score-analytics-utils";
|
||||
import { BarChart, LineChart } from "@tremor/react";
|
||||
import { NoDataOrLoading } from "@/src/components/NoDataOrLoading";
|
||||
import { Card } from "@/src/components/ui/card";
|
||||
import { type ChartBin } from "@/src/features/scores/types";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
|
||||
export function CategoricalChart(props: {
|
||||
chartData: ChartBin[];
|
||||
chartLabels: string[];
|
||||
isLoading?: boolean;
|
||||
stack?: boolean;
|
||||
showXAxis?: boolean;
|
||||
className?: string;
|
||||
chartClass?: string;
|
||||
}) {
|
||||
const barCategoryGap = (chartLength: number): string => {
|
||||
if (chartLength > 7) return "10%";
|
||||
if (chartLength > 5) return "20%";
|
||||
if (chartLength > 3) return "30%";
|
||||
else return "40%";
|
||||
};
|
||||
const colors = getColorsForCategories(props.chartLabels);
|
||||
|
||||
return isEmptyChart({ data: props.chartData }) ? (
|
||||
<NoDataOrLoading
|
||||
isLoading={props.isLoading ?? false}
|
||||
className={props.chartClass}
|
||||
/>
|
||||
) : (
|
||||
<Card
|
||||
className={cn("w-full rounded-tremor-default border", props.className)}
|
||||
>
|
||||
<BarChart
|
||||
className={cn("mt-4", props.chartClass)}
|
||||
data={props.chartData}
|
||||
index="binLabel"
|
||||
categories={props.chartLabels}
|
||||
colors={colors}
|
||||
valueFormatter={(number: number) =>
|
||||
Intl.NumberFormat("en-US").format(number).toString()
|
||||
}
|
||||
yAxisWidth={48}
|
||||
barCategoryGap={barCategoryGap(props.chartData.length)}
|
||||
stack={props.stack ?? true}
|
||||
showXAxis={props.showXAxis ?? true}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function NumericChart(props: {
|
||||
chartData: ChartBin[];
|
||||
chartLabels: string[];
|
||||
index: string;
|
||||
}) {
|
||||
const colors = getColorsForCategories(props.chartLabels);
|
||||
|
||||
return isEmptyChart({ data: props.chartData }) ? (
|
||||
<NoDataOrLoading isLoading={false} />
|
||||
) : (
|
||||
<Card className="h-full w-full rounded-tremor-default border">
|
||||
<LineChart
|
||||
className="h-full"
|
||||
data={props.chartData}
|
||||
index={props.index}
|
||||
categories={props.chartLabels}
|
||||
colors={colors}
|
||||
valueFormatter={compactNumberFormatter}
|
||||
noDataText="No data"
|
||||
showAnimation={true}
|
||||
onValueChange={() => {}}
|
||||
enableLegendSlider={true}
|
||||
showXAxis={false}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -11,17 +11,13 @@ import {
|
||||
type ScoreAggregate,
|
||||
} from "@langfuse/shared";
|
||||
import { type PromptVersionTableRow } from "@/src/pages/project/[projectId]/prompts/[promptName]/metrics";
|
||||
import { type ScoreDataType, type ScoreSource } from "@langfuse/shared";
|
||||
import { type ScoreDataType } from "@langfuse/shared";
|
||||
import { type Row } from "@tanstack/react-table";
|
||||
import React from "react";
|
||||
import { type TableRowTypesWithIndividualScoreColumns } from "@/src/features/scores/lib/types";
|
||||
|
||||
type ScoreDetailColumnProps = {
|
||||
key: string;
|
||||
name: string;
|
||||
dataType: ScoreDataType;
|
||||
source: ScoreSource;
|
||||
};
|
||||
import {
|
||||
type ScoreData,
|
||||
type TableRowTypesWithIndividualScoreColumns,
|
||||
} from "@/src/features/scores/lib/types";
|
||||
|
||||
const prefixScoreColKey = (
|
||||
key: string,
|
||||
@@ -48,7 +44,7 @@ const parseScoreColumn = <
|
||||
| DatasetRunItemRowData
|
||||
| PromptVersionTableRow,
|
||||
>(
|
||||
col: ScoreDetailColumnProps,
|
||||
col: ScoreData,
|
||||
prefix?: "Trace" | "Generation",
|
||||
): LangfuseColumnDef<T> => {
|
||||
const { key, name, source, dataType } = col;
|
||||
@@ -73,7 +69,7 @@ const parseScoreColumn = <
|
||||
};
|
||||
|
||||
export function verifyAndPrefixScoreDataAgainstKeys(
|
||||
scoreKeys: ScoreDetailColumnProps[],
|
||||
scoreKeys: ScoreData[],
|
||||
scoreData: ScoreAggregate,
|
||||
prefix?: "Trace" | "Generation",
|
||||
): ScoreAggregate {
|
||||
@@ -101,7 +97,7 @@ export const constructIndividualScoreColumns = <
|
||||
scoreColumnPrefix,
|
||||
cellsLoading = false,
|
||||
}: {
|
||||
scoreColumnProps: ScoreDetailColumnProps[];
|
||||
scoreColumnProps: ScoreData[];
|
||||
scoreColumnKey: keyof T & string;
|
||||
showAggregateViewOnly?: boolean;
|
||||
scoreColumnPrefix?: "Trace" | "Generation";
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
CategoricalChart,
|
||||
NumericChart,
|
||||
} from "@/src/features/scores/components/ScoreChart";
|
||||
import { type TimeseriesChartProps } from "@/src/features/scores/types";
|
||||
|
||||
function ChartWrapper(props: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-2 flex w-[80%] flex-none flex-col overflow-hidden md:w-[45%]">
|
||||
<div className="shrink-0 text-sm font-medium">{props.title}</div>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TimeseriesChart({
|
||||
chartData,
|
||||
chartLabels,
|
||||
title,
|
||||
type,
|
||||
index,
|
||||
}: TimeseriesChartProps) {
|
||||
const chartIndex = index ?? "binLabel";
|
||||
|
||||
return (
|
||||
<ChartWrapper title={title}>
|
||||
<div className="mt-2 min-h-0 flex-1">
|
||||
{type === "categorical" ? (
|
||||
<CategoricalChart
|
||||
chartLabels={chartLabels}
|
||||
chartData={chartData}
|
||||
className="h-full"
|
||||
chartClass="h-full mt-0"
|
||||
showXAxis={chartData.length < 3}
|
||||
/>
|
||||
) : (
|
||||
<NumericChart
|
||||
chartLabels={chartLabels}
|
||||
chartData={chartData}
|
||||
index={chartIndex}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ChartWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,21 @@
|
||||
import * as React from "react";
|
||||
import { Archive, Check, ChevronDown } from "lucide-react";
|
||||
import { Archive, ChevronDown, Component, Search } from "lucide-react";
|
||||
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { Badge } from "@/src/components/ui/badge";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Input } from "@/src/components/ui/input";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@/src/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/src/components/ui/popover";
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/src/components/ui/dropdown-menu";
|
||||
import { Separator } from "@/src/components/ui/separator";
|
||||
|
||||
type MultiSelectOptions = {
|
||||
@@ -28,6 +26,34 @@ type MultiSelectOptions = {
|
||||
isArchived?: boolean;
|
||||
};
|
||||
|
||||
type MultiSelectGroup = {
|
||||
label: string;
|
||||
options: MultiSelectOptions[];
|
||||
};
|
||||
|
||||
type MultiSelectKeyValuesProps<
|
||||
T extends { key: string; value: string } | string,
|
||||
> = {
|
||||
values: T[];
|
||||
onValueChange: (
|
||||
values: T[],
|
||||
changedValue?: string,
|
||||
selectedKeys?: Set<string>,
|
||||
) => void;
|
||||
options: MultiSelectOptions[] | readonly MultiSelectOptions[];
|
||||
title?: string;
|
||||
placeholder?: string;
|
||||
groupedOptions?: MultiSelectGroup[];
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
items?: string;
|
||||
align?: "center" | "end" | "start";
|
||||
controlButtons?: React.ReactNode;
|
||||
hideClearButton?: boolean;
|
||||
iconLeft?: React.ReactNode;
|
||||
iconRight?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function MultiSelectKeyValues<
|
||||
T extends { key: string; value: string } | string,
|
||||
>({
|
||||
@@ -36,29 +62,19 @@ export function MultiSelectKeyValues<
|
||||
values,
|
||||
onValueChange,
|
||||
options,
|
||||
groupedOptions,
|
||||
className,
|
||||
disabled,
|
||||
items = "items",
|
||||
align = "center",
|
||||
controlButtons,
|
||||
hideClearButton = false,
|
||||
}: {
|
||||
title?: string;
|
||||
placeholder?: string;
|
||||
values: T[];
|
||||
onValueChange: (
|
||||
values: T[],
|
||||
changedValue?: string,
|
||||
selectedKeys?: Set<string>,
|
||||
) => void;
|
||||
options: MultiSelectOptions[] | readonly MultiSelectOptions[];
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
items?: string;
|
||||
align?: "center" | "end" | "start";
|
||||
controlButtons?: React.ReactNode;
|
||||
hideClearButton?: boolean;
|
||||
}) {
|
||||
iconLeft,
|
||||
iconRight,
|
||||
}: MultiSelectKeyValuesProps<T>) {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [searchQuery, setSearchQuery] = React.useState("");
|
||||
|
||||
const selectedValueKeys = new Set(
|
||||
values.map((value) => (typeof value === "string" ? value : value.key)),
|
||||
);
|
||||
@@ -69,7 +85,11 @@ export function MultiSelectKeyValues<
|
||||
return Array.from(selectedValueKeys) as T[];
|
||||
}
|
||||
|
||||
return options
|
||||
const allOptions = groupedOptions
|
||||
? groupedOptions.flatMap((group) => group.options)
|
||||
: options || [];
|
||||
|
||||
return allOptions
|
||||
.filter((option) => !!option.key && selectedValueKeys.has(option.key))
|
||||
.map((option) => ({
|
||||
key: option.key as string,
|
||||
@@ -77,9 +97,77 @@ export function MultiSelectKeyValues<
|
||||
})) as T[];
|
||||
}
|
||||
|
||||
const filterOptions = (options: MultiSelectOptions[]) => {
|
||||
if (!searchQuery.trim()) return options;
|
||||
const searchLower = searchQuery.toLowerCase().trim();
|
||||
|
||||
return options.filter((option) => {
|
||||
const valueLower = option.value.toLowerCase();
|
||||
const keyLower = option.key?.toLowerCase() || "";
|
||||
return valueLower.includes(searchLower) || keyLower.includes(searchLower);
|
||||
});
|
||||
};
|
||||
|
||||
const renderOption = (option: MultiSelectOptions) => {
|
||||
const isSelected = selectedValueKeys.has(option.key ?? option.value);
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={option.key ?? option.value}
|
||||
checked={isSelected}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
onCheckedChange={() => {
|
||||
const value = option.key ?? option.value;
|
||||
if (isSelected) {
|
||||
selectedValueKeys.delete(value);
|
||||
} else {
|
||||
selectedValueKeys.add(value);
|
||||
}
|
||||
const filterValues = formatFilterValues();
|
||||
onValueChange(
|
||||
filterValues.length ? filterValues : [],
|
||||
value,
|
||||
selectedValueKeys,
|
||||
);
|
||||
}}
|
||||
disabled={option.disabled}
|
||||
className="group"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"capitalize",
|
||||
option.isArchived ? "text-foreground/50" : "",
|
||||
)}
|
||||
>
|
||||
{option.value}
|
||||
</span>
|
||||
{option.isArchived && (
|
||||
<Archive className="ml-2 h-4 w-4 text-foreground/50" />
|
||||
)}
|
||||
{option.count !== undefined && (
|
||||
<span className="ml-auto font-mono text-xs">{option.count}</span>
|
||||
)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
};
|
||||
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleInputClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover modal>
|
||||
<PopoverTrigger asChild>
|
||||
<DropdownMenu
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
if (!open) {
|
||||
setSearchQuery("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
@@ -88,7 +176,9 @@ export function MultiSelectKeyValues<
|
||||
)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{iconLeft}
|
||||
{title}
|
||||
{iconRight}
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
{selectedValueKeys.size > 0 && (
|
||||
<>
|
||||
@@ -126,91 +216,84 @@ export function MultiSelectKeyValues<
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align={align}>
|
||||
<Command>
|
||||
<CommandInput placeholder={placeholder} />
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{Boolean(options.length) ? (
|
||||
options.map((option) => {
|
||||
const isSelected = selectedValueKeys.has(
|
||||
option.key ?? option.value,
|
||||
);
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.key ?? option.value}
|
||||
value={option.key ?? option.value}
|
||||
keywords={[option.value]}
|
||||
onSelect={(value) => {
|
||||
if (isSelected) {
|
||||
selectedValueKeys.delete(value);
|
||||
} else {
|
||||
selectedValueKeys.add(value);
|
||||
}
|
||||
const filterValues = formatFilterValues();
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={align}
|
||||
className="w-[200px]"
|
||||
onPointerDownOutside={() => setIsOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="flex items-center border-b px-2 py-1"
|
||||
onClick={handleInputClick}
|
||||
>
|
||||
<Search className="mr-1 h-3 w-3 opacity-50" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder={placeholder}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-6 border-0 bg-transparent p-0 text-sm focus-visible:ring-0"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
{options &&
|
||||
options.length > 0 &&
|
||||
filterOptions(Array.from(options)).map(renderOption)}
|
||||
|
||||
onValueChange(
|
||||
filterValues.length ? filterValues : [],
|
||||
value,
|
||||
selectedValueKeys,
|
||||
);
|
||||
}}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50 [&_svg]:invisible",
|
||||
option.disabled ? "opacity-50" : null,
|
||||
)}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"overflow-x-scroll capitalize",
|
||||
option.isArchived ? "text-foreground/50" : "",
|
||||
)}
|
||||
>
|
||||
{option.value}
|
||||
</span>
|
||||
{option.isArchived ? (
|
||||
<div className="ml-1 mt-1 flex h-4 w-4">
|
||||
<Archive className="h-4 w-4 text-foreground/50"></Archive>
|
||||
</div>
|
||||
) : null}
|
||||
{option.count !== undefined ? (
|
||||
<span className="ml-auto flex h-4 w-4 items-center justify-center pl-1 font-mono text-xs">
|
||||
{option.count}
|
||||
</span>
|
||||
) : null}
|
||||
</CommandItem>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<CommandItem disabled>No options found.</CommandItem>
|
||||
)}
|
||||
</CommandGroup>
|
||||
{controlButtons || showClearItems ? (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Controls">
|
||||
{showClearItems && (
|
||||
<CommandItem onSelect={() => onValueChange([])}>
|
||||
Clear {items}
|
||||
</CommandItem>
|
||||
)}
|
||||
{controlButtons}
|
||||
</CommandGroup>
|
||||
</>
|
||||
) : null}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{groupedOptions?.map((group) => {
|
||||
const filteredGroupOptions = filterOptions(group.options);
|
||||
if (filteredGroupOptions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenuSub key={group.label}>
|
||||
<DropdownMenuSubTrigger className="flex w-full cursor-default select-none items-center">
|
||||
<Component className="mr-2 h-4 w-4 opacity-50" />
|
||||
<span>{group.label}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="max-h-[300px] overflow-y-auto">
|
||||
{filteredGroupOptions.map(renderOption)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
})}
|
||||
|
||||
{searchQuery &&
|
||||
(!options || filterOptions(Array.from(options)).length === 0) &&
|
||||
(!groupedOptions ||
|
||||
!groupedOptions.some(
|
||||
(group) => filterOptions(group.options).length > 0,
|
||||
)) && (
|
||||
<div className="px-2 py-1.5 text-sm text-muted-foreground">
|
||||
No results found.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showClearItems && !searchQuery && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
onValueChange([]);
|
||||
}}
|
||||
>
|
||||
Clear {items}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{controlButtons && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
{controlButtons}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { type TableRowTypesWithIndividualScoreColumns } from "@/src/features/sco
|
||||
import { constructIndividualScoreColumns } from "@/src/features/scores/components/ScoreDetailColumnHelpers";
|
||||
import { type TableDateRangeOptions } from "@/src/utils/date-range-utils";
|
||||
import { useClickhouse } from "@/src/components/layouts/ClickhouseAdminToggle";
|
||||
import { toOrderedScoresList } from "@/src/features/scores/lib/helpers";
|
||||
|
||||
export function useIndividualScoreColumns<
|
||||
T extends TableRowTypesWithIndividualScoreColumns,
|
||||
@@ -47,7 +48,9 @@ export function useIndividualScoreColumns<
|
||||
|
||||
const scoreColumns = useMemo(() => {
|
||||
return constructIndividualScoreColumns<T>({
|
||||
scoreColumnProps: scoreKeysAndProps.data ?? [],
|
||||
scoreColumnProps: scoreKeysAndProps.data
|
||||
? toOrderedScoresList(scoreKeysAndProps.data)
|
||||
: [],
|
||||
scoreColumnKey,
|
||||
scoreColumnPrefix,
|
||||
showAggregateViewOnly,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { type ScoreData } from "./types";
|
||||
import { ScoreDataType } from "@langfuse/shared";
|
||||
|
||||
export const isNumericDataType = (dataType: ScoreDataType) =>
|
||||
@@ -10,3 +11,6 @@ export const isBooleanDataType = (dataType: ScoreDataType) =>
|
||||
dataType === ScoreDataType.BOOLEAN;
|
||||
|
||||
export const isScoreUnsaved = (scoreId?: string): boolean => !scoreId;
|
||||
|
||||
export const toOrderedScoresList = (list: ScoreData[]): ScoreData[] =>
|
||||
list.sort((a, b) => a.key.localeCompare(b.key));
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type TracesTableRow } from "@/src/components/table/use-cases/traces";
|
||||
import { type DatasetRunItemRowData } from "@/src/features/datasets/components/DatasetRunItemsTable";
|
||||
import { type DatasetRunRowData } from "@/src/features/datasets/components/DatasetRunsTable";
|
||||
import { type PromptVersionTableRow } from "@/src/pages/project/[projectId]/prompts/[promptName]/metrics";
|
||||
import { type ScoreDataType, type ScoreSource } from "@langfuse/shared";
|
||||
|
||||
export type TableRowTypesWithIndividualScoreColumns =
|
||||
| GenerationsTableRow
|
||||
@@ -10,3 +11,10 @@ export type TableRowTypesWithIndividualScoreColumns =
|
||||
| DatasetRunItemRowData
|
||||
| DatasetRunRowData
|
||||
| PromptVersionTableRow;
|
||||
|
||||
export type ScoreData = {
|
||||
key: string;
|
||||
name: string;
|
||||
dataType: ScoreDataType;
|
||||
source: ScoreSource;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { type ScoreDataType } from "@langfuse/shared";
|
||||
|
||||
export type HistogramBin = { binLabel: string; count: number };
|
||||
export type CategoryCounts = Record<string, number>;
|
||||
export type ChartBin = { binLabel: string } & CategoryCounts;
|
||||
|
||||
export type TimeseriesChartProps = {
|
||||
chartData: ChartBin[];
|
||||
chartLabels: string[];
|
||||
title: string;
|
||||
type: "numeric" | "categorical";
|
||||
index?: string;
|
||||
};
|
||||
|
||||
export type ChartData = {
|
||||
chartData: ChartBin[];
|
||||
chartLabels: string[];
|
||||
};
|
||||
|
||||
export type ScoreData = {
|
||||
key: string;
|
||||
name: string;
|
||||
dataType: ScoreDataType;
|
||||
source: string;
|
||||
};
|
||||
|
||||
// Adapter interface to standardize data transformation
|
||||
export interface TimeseriesDataTransformer {
|
||||
toChartData(): ChartData;
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
inviteMembersRoute,
|
||||
setupTracingRoute,
|
||||
} from "@/src/features/setup/setupRoutes";
|
||||
import { showChat } from "@/src/features/support-chat/chat";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { type RouterOutput } from "@/src/utils/types";
|
||||
@@ -226,7 +227,10 @@ const TracingSetup = ({
|
||||
>(null);
|
||||
const utils = api.useUtils();
|
||||
const mutCreateApiKey = api.apiKeys.create.useMutation({
|
||||
onSuccess: () => utils.apiKeys.invalidate(),
|
||||
onSuccess: () => {
|
||||
utils.apiKeys.invalidate();
|
||||
showChat();
|
||||
},
|
||||
});
|
||||
const isLoadingRef = useRef(false);
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { setupTracingRoute } from "@/src/features/setup/setupRoutes";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import { LockIcon } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
|
||||
const SetupTracingButton = () => {
|
||||
@@ -27,10 +27,13 @@ const SetupTracingButton = () => {
|
||||
},
|
||||
);
|
||||
|
||||
// dedupe result via useRef, otherwise we'll capture the event multiple times on session refresh
|
||||
const capturedEventAlready = useRef<boolean | undefined>(undefined);
|
||||
const capture = usePostHogClientCapture();
|
||||
useEffect(() => {
|
||||
if (hasAnyTrace !== undefined) {
|
||||
if (hasAnyTrace !== undefined && !capturedEventAlready.current) {
|
||||
capture("onboarding:tracing_check_active", { active: hasAnyTrace });
|
||||
capturedEventAlready.current = true;
|
||||
}
|
||||
}, [hasAnyTrace, capture]);
|
||||
|
||||
|
||||
@@ -11,6 +11,14 @@ const CrispChat = () => {
|
||||
useEffect(() => {
|
||||
if (env.NEXT_PUBLIC_CRISP_WEBSITE_ID) {
|
||||
Crisp.configure(env.NEXT_PUBLIC_CRISP_WEBSITE_ID);
|
||||
|
||||
// Check session storage for chat visibility
|
||||
// Expires at end of session
|
||||
const shouldShowChat =
|
||||
sessionStorage.getItem("supportChatVisible") === "true";
|
||||
if (!shouldShowChat) {
|
||||
Crisp.chat.hide();
|
||||
}
|
||||
Crisp.chat.onChatInitiated(() => {
|
||||
capture("support_chat:initiated");
|
||||
});
|
||||
@@ -20,13 +28,24 @@ const CrispChat = () => {
|
||||
Crisp.message.onMessageSent(() => {
|
||||
capture("support_chat:message_sent");
|
||||
});
|
||||
Crisp.message.onMessageReceived(() => {
|
||||
showChat();
|
||||
});
|
||||
try {
|
||||
if (Crisp.chat.unreadCount() > 0) {
|
||||
showChat();
|
||||
}
|
||||
} catch (e) {
|
||||
// do nothing, this throws unnecessary errors that cannot be fixed
|
||||
}
|
||||
return () => {
|
||||
Crisp.chat.offChatInitiated();
|
||||
Crisp.chat.offChatOpened();
|
||||
Crisp.message.offMessageSent();
|
||||
};
|
||||
}
|
||||
}, [capture]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -78,7 +97,22 @@ export const showAgentChatMessage = (message: string) => {
|
||||
};
|
||||
|
||||
export const openChat = () => {
|
||||
showChat();
|
||||
if (chatAvailable) Crisp.chat.open();
|
||||
};
|
||||
|
||||
export const hideChat = () => {
|
||||
if (chatAvailable) {
|
||||
sessionStorage.setItem("supportChatVisible", "false");
|
||||
Crisp.chat.hide();
|
||||
}
|
||||
};
|
||||
|
||||
export const showChat = () => {
|
||||
if (chatAvailable) {
|
||||
sessionStorage.setItem("supportChatVisible", "true");
|
||||
Crisp.chat.show();
|
||||
}
|
||||
};
|
||||
|
||||
export const chatAvailable = !!process.env.NEXT_PUBLIC_CRISP_WEBSITE_ID;
|
||||
|
||||
@@ -2,7 +2,12 @@ import { VERSION } from "@/src/constants";
|
||||
import { ServerPosthog } from "@/src/features/posthog-analytics/ServerPosthog";
|
||||
import { Prisma, prisma } from "@langfuse/shared/src/db";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { logger } from "@langfuse/shared/src/server";
|
||||
import {
|
||||
getObservationCountsByProjectInCreationInterval,
|
||||
getScoreCountsByProjectInCreationInterval,
|
||||
getTraceCountsByProjectInCreationInterval,
|
||||
logger,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { env } from "@/src/env.mjs";
|
||||
|
||||
// Interval between jobs in milliseconds
|
||||
@@ -159,34 +164,37 @@ async function posthogTelemetry({
|
||||
});
|
||||
|
||||
// Count traces
|
||||
const countTraces = await prisma.trace.count({
|
||||
where: {
|
||||
timestamp: {
|
||||
gte: startTimeframe?.toISOString(),
|
||||
lt: endTimeframe.toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
const countTracesClickhouse =
|
||||
await getTraceCountsByProjectInCreationInterval({
|
||||
start: startTimeframe ?? new Date(0),
|
||||
end: endTimeframe,
|
||||
});
|
||||
const countTraces = countTracesClickhouse.reduce(
|
||||
(acc, curr) => acc + curr.count,
|
||||
0,
|
||||
);
|
||||
|
||||
// Count scores
|
||||
const countScores = await prisma.score.count({
|
||||
where: {
|
||||
timestamp: {
|
||||
gte: startTimeframe?.toISOString(),
|
||||
lt: endTimeframe.toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
const countScoresClickhouse =
|
||||
await getScoreCountsByProjectInCreationInterval({
|
||||
start: startTimeframe ?? new Date(0),
|
||||
end: endTimeframe,
|
||||
});
|
||||
const countScores = countScoresClickhouse.reduce(
|
||||
(acc, curr) => acc + curr.count,
|
||||
0,
|
||||
);
|
||||
|
||||
// Count observations
|
||||
const countObservations = await prisma.observation.count({
|
||||
where: {
|
||||
startTime: {
|
||||
gte: startTimeframe?.toISOString(),
|
||||
lt: endTimeframe.toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
const countObservationsClickhouse =
|
||||
await getObservationCountsByProjectInCreationInterval({
|
||||
start: startTimeframe ?? new Date(0),
|
||||
end: endTimeframe,
|
||||
});
|
||||
const countObservations = countObservationsClickhouse.reduce(
|
||||
(acc, curr) => acc + curr.count,
|
||||
0,
|
||||
);
|
||||
|
||||
// Count datasets
|
||||
const countDatasets = await prisma.dataset.count({
|
||||
|
||||
+14
-5
@@ -12,7 +12,7 @@ import { QueryParamProvider } from "use-query-params";
|
||||
|
||||
import "@/src/styles/globals.css";
|
||||
import Layout from "@/src/components/layouts/layout";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import posthog from "posthog-js";
|
||||
@@ -62,6 +62,7 @@ if (
|
||||
if (process.env.NODE_ENV === "development") posthog.debug();
|
||||
},
|
||||
autocapture: false,
|
||||
enable_heatmaps: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -126,8 +127,16 @@ function UserTracking() {
|
||||
const sessionUser = session.data?.user;
|
||||
const { organization, project } = useQueryProjectOrOrganization();
|
||||
|
||||
// dedupe the event via useRef, otherwise we'll capture the event multiple times on session refresh
|
||||
const lastIdentifiedUser = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionUser) {
|
||||
if (
|
||||
session.status === "authenticated" &&
|
||||
sessionUser &&
|
||||
lastIdentifiedUser.current !== JSON.stringify(sessionUser)
|
||||
) {
|
||||
lastIdentifiedUser.current = JSON.stringify(sessionUser);
|
||||
// PostHog
|
||||
if (env.NEXT_PUBLIC_POSTHOG_KEY && env.NEXT_PUBLIC_POSTHOG_HOST)
|
||||
posthog.identify(sessionUser.id ?? undefined, {
|
||||
@@ -171,7 +180,8 @@ function UserTracking() {
|
||||
: "undefined",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
} else if (session.status === "unauthenticated") {
|
||||
lastIdentifiedUser.current = null;
|
||||
// PostHog
|
||||
if (env.NEXT_PUBLIC_POSTHOG_KEY && env.NEXT_PUBLIC_POSTHOG_HOST) {
|
||||
posthog.reset();
|
||||
@@ -180,7 +190,7 @@ function UserTracking() {
|
||||
// Sentry
|
||||
setUser(null);
|
||||
}
|
||||
}, [sessionUser]);
|
||||
}, [sessionUser, session.status]);
|
||||
|
||||
// update crisp segments
|
||||
const plan = organization?.plan;
|
||||
@@ -193,7 +203,6 @@ function UserTracking() {
|
||||
useEffect(() => {
|
||||
let segments = [];
|
||||
if (plan && !currentOrgIsDemoOrg) {
|
||||
console.log("setting chat segments", plan);
|
||||
segments.push("plan:" + plan);
|
||||
}
|
||||
if (currentOrgIsDemoOrg) {
|
||||
|
||||
@@ -165,7 +165,7 @@ export default async function handler(
|
||||
// return not implemented error
|
||||
res.status(404).json({ error: "Action does not exist" });
|
||||
} catch (e) {
|
||||
logger.error("failed to remove API keys", e);
|
||||
logger.error("failed to manage bullmq jobs", e);
|
||||
res.status(500).json({ error: e });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { ServerPosthog } from "@/src/features/posthog-analytics/ServerPosthog";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { type NextApiRequest, type NextApiResponse } from "next";
|
||||
import { logger } from "@langfuse/shared/src/server";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
if (!process.env.NEXT_PUBLIC_POSTHOG_KEY)
|
||||
return res.status(200).json({ message: "No PostHog key provided" });
|
||||
|
||||
if (
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === undefined ||
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "DEV"
|
||||
)
|
||||
return res.status(200).json({
|
||||
message: "Only runs on Langfuse Cloud, no LANGFUSE_CLOUD_REGION provided",
|
||||
});
|
||||
|
||||
if (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "STAGING")
|
||||
return res.status(200).json({
|
||||
message: "Does not run on staging, LANGFUSE_CLOUD_REGION is STAGING",
|
||||
});
|
||||
|
||||
const posthog_event_user_id =
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "US"
|
||||
? "langfuse-cloud-us"
|
||||
: "langfuse-cloud-eu";
|
||||
|
||||
try {
|
||||
const posthog = new ServerPosthog();
|
||||
|
||||
// Time frame is the last time this cron job ran until now
|
||||
const startTimeframe =
|
||||
(
|
||||
await prisma.cronJobs.findUnique({
|
||||
where: { name: "ingestion_metrics" },
|
||||
})
|
||||
)?.lastRun ?? undefined;
|
||||
const endTimeframe = new Date(Date.now());
|
||||
|
||||
// db size
|
||||
const dbSize = await prisma.$queryRaw<
|
||||
Array<{
|
||||
size_in_mb: number;
|
||||
}>
|
||||
>`
|
||||
SELECT (pg_database_size('postgres') / 1024^2)::integer AS size_in_mb
|
||||
`;
|
||||
if (dbSize[0])
|
||||
posthog.capture({
|
||||
event: "ingestion_metrics",
|
||||
distinctId: posthog_event_user_id,
|
||||
properties: {
|
||||
total_db_size_in_mb: dbSize[0].size_in_mb,
|
||||
},
|
||||
});
|
||||
|
||||
await posthog.shutdown();
|
||||
|
||||
logger.info(
|
||||
"Updated ingestion_metrics in PostHog from startTimeframe:",
|
||||
startTimeframe?.toISOString(),
|
||||
"to endTimeframe:",
|
||||
endTimeframe.toISOString(),
|
||||
{
|
||||
"db size in MB": dbSize[0]?.size_in_mb,
|
||||
},
|
||||
);
|
||||
|
||||
await prisma.cronJobs.upsert({
|
||||
where: { name: "ingestion_metrics" },
|
||||
update: { lastRun: endTimeframe },
|
||||
create: { name: "ingestion_metrics", lastRun: endTimeframe },
|
||||
});
|
||||
|
||||
return res.status(200).json({ message: "OK" });
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return res.status(500).json({ message: "Internal server error" });
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
PostDatasetItemsV1Response,
|
||||
transformDbDatasetItemToAPIDatasetItem,
|
||||
} from "@/src/features/public-api/types/datasets";
|
||||
import { LangfuseNotFoundError } from "@langfuse/shared";
|
||||
import {
|
||||
type DatasetItem,
|
||||
LangfuseNotFoundError,
|
||||
Prisma,
|
||||
} from "@langfuse/shared";
|
||||
import { logger } from "@langfuse/shared/src/server";
|
||||
|
||||
export default withMiddlewares({
|
||||
POST: createAuthedAPIRoute({
|
||||
@@ -40,34 +45,54 @@ export default withMiddlewares({
|
||||
|
||||
const itemId = id ?? uuidv4();
|
||||
|
||||
const item = await prisma.datasetItem.upsert({
|
||||
where: {
|
||||
datasetId: dataset.id,
|
||||
id_projectId: {
|
||||
projectId: auth.scope.projectId,
|
||||
id: itemId,
|
||||
let item: DatasetItem;
|
||||
try {
|
||||
item = await prisma.datasetItem.upsert({
|
||||
where: {
|
||||
datasetId: dataset.id,
|
||||
id_projectId: {
|
||||
projectId: auth.scope.projectId,
|
||||
id: itemId,
|
||||
},
|
||||
},
|
||||
},
|
||||
create: {
|
||||
id: itemId,
|
||||
input: input ?? undefined,
|
||||
expectedOutput: expectedOutput ?? undefined,
|
||||
datasetId: dataset.id,
|
||||
metadata: metadata ?? undefined,
|
||||
sourceTraceId: sourceTraceId ?? undefined,
|
||||
sourceObservationId: sourceObservationId ?? undefined,
|
||||
status: status ?? undefined,
|
||||
projectId: auth.scope.projectId,
|
||||
},
|
||||
update: {
|
||||
input: input ?? undefined,
|
||||
expectedOutput: expectedOutput ?? undefined,
|
||||
metadata: metadata ?? undefined,
|
||||
sourceTraceId: sourceTraceId ?? undefined,
|
||||
sourceObservationId: sourceObservationId ?? undefined,
|
||||
status: status ?? undefined,
|
||||
},
|
||||
});
|
||||
create: {
|
||||
id: itemId,
|
||||
input: input ?? undefined,
|
||||
expectedOutput: expectedOutput ?? undefined,
|
||||
datasetId: dataset.id,
|
||||
metadata: metadata ?? undefined,
|
||||
sourceTraceId: sourceTraceId ?? undefined,
|
||||
sourceObservationId: sourceObservationId ?? undefined,
|
||||
status: status ?? undefined,
|
||||
projectId: auth.scope.projectId,
|
||||
},
|
||||
update: {
|
||||
input: input ?? undefined,
|
||||
expectedOutput: expectedOutput ?? undefined,
|
||||
metadata: metadata ?? undefined,
|
||||
sourceTraceId: sourceTraceId ?? undefined,
|
||||
sourceObservationId: sourceObservationId ?? undefined,
|
||||
status: status ?? undefined,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
e.code === "P2025"
|
||||
) {
|
||||
// this case happens when a dataset item was created for a different dataset.
|
||||
// In the database, the uniqueness constraint is on (id, projectId) only.
|
||||
// When this constraint is violated, the database will upsert based on (id, projectId, datasetId).
|
||||
// If this record does not exist, the database will throw an error.
|
||||
logger.warn(
|
||||
`Failed to upsert dataset item. Dataset item ${itemId} in project ${auth.scope.projectId} already exists for a different dataset than ${dataset.id}`,
|
||||
);
|
||||
throw new LangfuseNotFoundError(
|
||||
`The dataset item with id ${itemId} was not found in the dataset ${dataset.name}`,
|
||||
);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
return transformDbDatasetItemToAPIDatasetItem({
|
||||
...item,
|
||||
|
||||
@@ -21,6 +21,20 @@ import {
|
||||
} from "@/src/components/ui/dialog";
|
||||
import { CreateExperimentsForm } from "@/src/ee/features/experiments/components/CreateExperimentsForm";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import { DatasetAnalytics } from "@/src/features/datasets/components/DatasetAnalytics";
|
||||
import { Card, CardContent } from "@/src/components/ui/card";
|
||||
import { getScoreDataTypeIcon } from "@/src/features/scores/components/ScoreDetailColumnHelpers";
|
||||
import { useClickhouse } from "@/src/components/layouts/ClickhouseAdminToggle";
|
||||
import { TimeseriesChart } from "@/src/features/scores/components/TimeseriesChart";
|
||||
import {
|
||||
isNumericDataType,
|
||||
toOrderedScoresList,
|
||||
} from "@/src/features/scores/lib/helpers";
|
||||
import { CompareViewAdapter } from "@/src/features/scores/adapters";
|
||||
import {
|
||||
RESOURCE_METRICS,
|
||||
transformAggregatedRunMetricsToChartData,
|
||||
} from "@/src/features/dashboard/lib/score-analytics-utils";
|
||||
|
||||
export default function DatasetCompare() {
|
||||
const router = useRouter();
|
||||
@@ -29,11 +43,15 @@ export default function DatasetCompare() {
|
||||
const [runState, setRunState] = useQueryParams({
|
||||
runs: withDefault(ArrayParam, []),
|
||||
});
|
||||
|
||||
const [isCreateExperimentDialogOpen, setIsCreateExperimentDialogOpen] =
|
||||
useState(false);
|
||||
const [localRuns, setLocalRuns] = useState<
|
||||
Array<{ key: string; value: string }>
|
||||
>([]);
|
||||
const [selectedMetrics, setSelectedMetrics] = useState<string[]>(
|
||||
RESOURCE_METRICS.map((metric) => metric.key),
|
||||
);
|
||||
const runIds = runState.runs as undefined | string[];
|
||||
|
||||
const hasExperimentWriteAccess = useHasProjectAccess({
|
||||
@@ -59,6 +77,61 @@ export default function DatasetCompare() {
|
||||
);
|
||||
const utils = api.useUtils();
|
||||
|
||||
const runMetrics = api.datasets.runsByDatasetIdMetrics.useQuery(
|
||||
{
|
||||
projectId,
|
||||
datasetId,
|
||||
queryClickhouse: useClickhouse(),
|
||||
runIds: runIds,
|
||||
},
|
||||
{
|
||||
enabled: runIds && runIds.length > 1,
|
||||
},
|
||||
);
|
||||
|
||||
// LFE-3236: refactor to filter query to only include scores for runs in runIds
|
||||
const scoreKeysAndProps = api.scores.getScoreKeysAndProps.useQuery(
|
||||
{
|
||||
projectId: projectId,
|
||||
selectedTimeOption: { option: "All time", filterSource: "TABLE" },
|
||||
queryClickhouse: useClickhouse(),
|
||||
},
|
||||
{
|
||||
enabled: runIds && runIds.length > 1,
|
||||
},
|
||||
);
|
||||
|
||||
const scoreIdToName = useMemo(() => {
|
||||
return new Map(
|
||||
scoreKeysAndProps.data?.map((obj) => [obj.key, obj.name]) ?? [],
|
||||
);
|
||||
}, [scoreKeysAndProps.data]);
|
||||
|
||||
const runAggregatedMetrics = useMemo(() => {
|
||||
return transformAggregatedRunMetricsToChartData(
|
||||
runMetrics.data?.runs.filter((run) => runIds?.includes(run.id)) ?? [],
|
||||
scoreIdToName,
|
||||
);
|
||||
}, [runMetrics.data, runIds, scoreIdToName]);
|
||||
|
||||
const { scoreAnalyticsOptions, scoreKeyToData } = useMemo(() => {
|
||||
const scoreAnalyticsOptions = scoreKeysAndProps.data
|
||||
? toOrderedScoresList(scoreKeysAndProps.data).map(
|
||||
({ key, name, dataType, source }) => ({
|
||||
key,
|
||||
value: `${getScoreDataTypeIcon(dataType)} ${name} (${source.toLowerCase()})`,
|
||||
}),
|
||||
)
|
||||
: [];
|
||||
|
||||
return {
|
||||
scoreAnalyticsOptions,
|
||||
scoreKeyToData: new Map(
|
||||
scoreKeysAndProps.data?.map((obj) => [obj.key, obj]) ?? [],
|
||||
),
|
||||
};
|
||||
}, [scoreKeysAndProps.data]);
|
||||
|
||||
const handleExperimentSettled = async (data?: {
|
||||
success: boolean;
|
||||
datasetId: string;
|
||||
@@ -89,7 +162,7 @@ export default function DatasetCompare() {
|
||||
}
|
||||
|
||||
return (
|
||||
<FullScreenPage key={runIds?.join(",") ?? "empty"}>
|
||||
<FullScreenPage>
|
||||
<Header
|
||||
title={`Compare runs: ${dataset.data?.name ?? datasetId}`}
|
||||
breadcrumb={[
|
||||
@@ -154,6 +227,15 @@ export default function DatasetCompare() {
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>,
|
||||
runIds && runIds.length > 1 ? (
|
||||
<DatasetAnalytics
|
||||
key="dataset-analytics"
|
||||
projectId={projectId}
|
||||
scoreOptions={scoreAnalyticsOptions}
|
||||
selectedMetrics={selectedMetrics}
|
||||
setSelectedMetrics={setSelectedMetrics}
|
||||
/>
|
||||
) : null,
|
||||
<MultiSelectKeyValues
|
||||
key="select-runs"
|
||||
title="Select runs"
|
||||
@@ -185,6 +267,52 @@ export default function DatasetCompare() {
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
{Boolean(selectedMetrics.length) &&
|
||||
Boolean(runAggregatedMetrics?.size) && (
|
||||
<Card className="my-4 max-h-[25dvh] md:max-h-[30dvh]">
|
||||
<CardContent className="mt-2 h-full">
|
||||
<div className="flex h-full w-full gap-4 overflow-x-auto">
|
||||
{selectedMetrics.map((key) => {
|
||||
const adapter = new CompareViewAdapter(
|
||||
runAggregatedMetrics,
|
||||
key,
|
||||
);
|
||||
const { chartData, chartLabels } = adapter.toChartData();
|
||||
|
||||
const scoreData = scoreKeyToData.get(key);
|
||||
if (!scoreData)
|
||||
return (
|
||||
<TimeseriesChart
|
||||
key={key}
|
||||
chartData={chartData}
|
||||
chartLabels={chartLabels}
|
||||
title={
|
||||
RESOURCE_METRICS.find((metric) => metric.key === key)
|
||||
?.label ?? key
|
||||
}
|
||||
type="numeric"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<TimeseriesChart
|
||||
key={key}
|
||||
chartData={chartData}
|
||||
chartLabels={chartLabels}
|
||||
title={`${getScoreDataTypeIcon(scoreData.dataType)} ${scoreData.name} (${scoreData.source.toLowerCase()})`}
|
||||
type={
|
||||
isNumericDataType(scoreData.dataType)
|
||||
? "numeric"
|
||||
: "categorical"
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<DatasetCompareRunsTable
|
||||
key={runIds?.join(",") ?? "empty"}
|
||||
projectId={projectId}
|
||||
|
||||
@@ -7,13 +7,11 @@ import Link from "next/link";
|
||||
import { DetailPageNav } from "@/src/features/navigate-detail-pages/DetailPageNav";
|
||||
import { DatasetActionButton } from "@/src/features/datasets/components/DatasetActionButton";
|
||||
import { DeleteButton } from "@/src/components/deleteButton";
|
||||
import { JSONView } from "@/src/components/ui/CodeJsonViewer";
|
||||
import { FullScreenPage } from "@/src/components/layouts/full-screen-page";
|
||||
import { DuplicateDatasetButton } from "@/src/features/datasets/components/DuplicateDatasetButton";
|
||||
import { useState } from "react";
|
||||
import { MultiSelectKeyValues } from "@/src/features/scores/components/multi-select-key-values";
|
||||
import { CommandItem } from "@/src/components/ui/command";
|
||||
import { ExternalLink, FlaskConical } from "lucide-react";
|
||||
import { ExternalLink, FlaskConical, FolderKanban } from "lucide-react";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import { useMemo } from "react";
|
||||
import { useHasEntitlement } from "@/src/features/entitlements/hooks";
|
||||
@@ -25,6 +23,15 @@ import {
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { CreateExperimentsForm } from "@/src/ee/features/experiments/components/CreateExperimentsForm";
|
||||
import { showSuccessToast } from "@/src/features/notifications/showSuccessToast";
|
||||
import { DropdownMenuItem } from "@/src/components/ui/dropdown-menu";
|
||||
import { DatasetAnalytics } from "@/src/features/datasets/components/DatasetAnalytics";
|
||||
import { RESOURCE_METRICS } from "@/src/features/dashboard/lib/score-analytics-utils";
|
||||
import { MarkdownOrJsonView } from "@/src/components/trace/IOPreview";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/src/components/ui/popover";
|
||||
|
||||
export default function Dataset() {
|
||||
const router = useRouter();
|
||||
@@ -34,6 +41,15 @@ export default function Dataset() {
|
||||
const hasEntitlement = useHasEntitlement("model-based-evaluations");
|
||||
const [isCreateExperimentDialogOpen, setIsCreateExperimentDialogOpen] =
|
||||
useState(false);
|
||||
const [selectedMetrics, setSelectedMetrics] = useState<string[]>(
|
||||
RESOURCE_METRICS.map((metric) => metric.key),
|
||||
);
|
||||
const [scoreOptions, setScoreOptions] = useState<
|
||||
{
|
||||
key: string;
|
||||
value: string;
|
||||
}[]
|
||||
>([]);
|
||||
|
||||
const dataset = api.datasets.byId.useQuery({
|
||||
datasetId,
|
||||
@@ -132,6 +148,14 @@ export default function Dataset() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<DatasetAnalytics
|
||||
key="dataset-analytics"
|
||||
projectId={projectId}
|
||||
scoreOptions={scoreOptions}
|
||||
selectedMetrics={selectedMetrics}
|
||||
setSelectedMetrics={setSelectedMetrics}
|
||||
/>
|
||||
|
||||
{hasReadAccess && hasEntitlement && evaluators.isSuccess && (
|
||||
<MultiSelectKeyValues
|
||||
className="max-w-fit"
|
||||
@@ -148,17 +172,41 @@ export default function Dataset() {
|
||||
values={evaluatorsOptions}
|
||||
options={evaluatorsOptions}
|
||||
controlButtons={
|
||||
<CommandItem
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
window.open(`/project/${projectId}/evals`, "_blank");
|
||||
}}
|
||||
>
|
||||
Manage evaluators
|
||||
<ExternalLink className="ml-auto h-4 w-4" />
|
||||
</CommandItem>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Popover key="show-dataset-details">
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<FolderKanban className="mr-2 h-4 w-4" />
|
||||
Dataset details
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="mx-2 max-h-[50vh] w-[50vw] overflow-y-auto md:w-[25vw]">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="mb-1 font-medium">Description</h4>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{dataset.data?.description ?? "No description"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="mb-1 font-medium">Metadata</h4>
|
||||
<MarkdownOrJsonView
|
||||
content={dataset.data?.metadata ?? null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<DetailPageNav
|
||||
currentId={datasetId}
|
||||
path={(entry) => `/project/${projectId}/datasets/${entry.id}`}
|
||||
@@ -190,17 +238,12 @@ export default function Dataset() {
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{!!dataset.data?.metadata && (
|
||||
<JSONView
|
||||
json={dataset?.data.metadata}
|
||||
title="Metadata"
|
||||
className="max-h-[25vh] overflow-y-auto"
|
||||
/>
|
||||
)}
|
||||
|
||||
<DatasetRunsTable
|
||||
projectId={projectId}
|
||||
datasetId={datasetId}
|
||||
selectedMetrics={selectedMetrics}
|
||||
setScoreOptions={setScoreOptions}
|
||||
menuItems={
|
||||
<Tabs value="runs">
|
||||
<TabsList>
|
||||
|
||||
@@ -176,7 +176,6 @@ export default function Dashboard() {
|
||||
<FeedbackButtonWrapper
|
||||
title="Request Chart"
|
||||
description="Your feedback matters! Let the Langfuse team know what additional data or metrics you'd like to see in your dashboard."
|
||||
type="dashboard"
|
||||
className="hidden lg:flex"
|
||||
>
|
||||
<Button
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import Header from "@/src/components/layouts/header";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { FullScreenPage } from "@/src/components/layouts/full-screen-page";
|
||||
import Header from "@/src/components/layouts/header";
|
||||
import ModelTable from "@/src/components/table/use-cases/models";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import { Lock } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { UpsertModelFormDrawer } from "@/src/features/models/components/UpsertModelFormDrawer";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { FullScreenPage } from "@/src/components/layouts/full-screen-page";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
|
||||
export default function ModelsPage() {
|
||||
const router = useRouter();
|
||||
@@ -27,19 +26,15 @@ export default function ModelsPage() {
|
||||
href: "https://langfuse.com/docs/model-usage-and-cost",
|
||||
}}
|
||||
actionButtons={
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={!hasWriteAccess}
|
||||
onClick={() => capture("models:new_form_open")}
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={hasWriteAccess ? `/project/${projectId}/models/new` : "#"}
|
||||
<UpsertModelFormDrawer {...{ projectId, action: "create" }}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={!hasWriteAccess}
|
||||
onClick={() => capture("models:new_form_open")}
|
||||
>
|
||||
{!hasWriteAccess && <Lock size={16} className="mr-2" />}
|
||||
Add model definition
|
||||
</Link>
|
||||
</Button>
|
||||
</Button>
|
||||
</UpsertModelFormDrawer>
|
||||
}
|
||||
/>
|
||||
<ModelTable projectId={projectId} />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user