Compare commits

...
70 Commits
Author SHA1 Message Date
Marc Klingen c79c84ea96 chore: release v3.6.2 2025-01-10 14:49:19 +01:00
Hassieb PakzadandGitHub 4634669fcd chore(docker-compose): add ingestion env vars (#4969) 2025-01-10 14:47:25 +01:00
Marc KlingenandGitHub 549e0eb629 chore: daily instead of hourly telemetry (#4968) 2025-01-10 14:10:51 +01:00
Steffen SchmitzandGitHub 80adf63fe6 fix: adjust deletion order for apikey invalidation on project delete (#4964) 2025-01-10 10:24:26 +00:00
Hassieb Pakzad 6c1d7534bb chore: release v3.6.1 2025-01-10 09:15:57 +01:00
Max DeichmannandGitHub 23a4bbd2cb fix: fix trace exports (#4958)
* fix: fix trace exports

* push

* fix: fix trace exports
2025-01-09 19:36:07 +00:00
Steffen SchmitzandGitHub 07d048d46f chore: enable clickhouse cluster mode by default (#4956) 2025-01-09 16:32:55 +00:00
Hassieb PakzadandGitHub bd7226bbb8 fix(pubic-api): observations and traces FINAL (#4951) 2025-01-09 17:41:35 +01:00
Steffen SchmitzandGitHub fe70d92211 chore: set created_at in ClickHouse based on min S3 timestamp (#4955)
* chore: set created_at in ClickHouse based on min S3 timestamp

* chore: fix worker tests
2025-01-09 16:12:23 +00:00
Max DeichmannandGitHub bb3621f31d fix: sessions metrics api performance (#4954) 2025-01-09 17:15:30 +01:00
Steffen SchmitzandGitHub 63225a9fd6 chore: accept projectId list which skips clickhouse reads during ingestion (#4952) 2025-01-09 14:13:23 +00:00
Max DeichmannandGitHub 947da9cc84 fix: sessions table id filter (#4953) 2025-01-09 14:33:09 +01:00
Max DeichmannandGitHub 93a0ab6f3a fix: remove trace tracesession fk (#4946)
* fix: remove trace tracesession fk

* push

* push

* push

* fix: remove trace tracesession fk

* fix: remove trace tracesession fk

* fix: remove trace tracesession fk
2025-01-09 09:50:58 +00:00
Hassieb PakzadandGitHub 68ad079d8c fix(models-table): sorting across pages (#4947) 2025-01-09 10:30:00 +01:00
Max DeichmannandGitHub cf29c6b7e4 feat: backup redis queues to postgres (#4943)
feat: backup redis queuer
2025-01-08 23:15:03 +00:00
Max DeichmannandGitHub e4d2a5ec6c perf: improve session all time search performance (#4926)
* push

* fix: correctly export categorical scores for trace exports

* fixes

* push

* Merge branch 'max/lfe-3345-fix-session-table-all-time-search' of https://github.com/langfuse/langfuse into max/lfe-3345-fix-session-table-all-time-search

* fix

* fixes
2025-01-08 16:54:01 +00:00
Steffen SchmitzandGitHub 35f3092a1c feat: retry 429 errors for eval executions for up to 24h (#4933)
* feat: retry 429 errors for eval executions for up to 24h

* chore: skip type error retries for eval execution
2025-01-08 14:51:36 +00:00
Hassieb PakzadandGitHub 7b222318e1 chore(cost): add langchain-compatible price keys (#4932) 2025-01-08 15:49:21 +01:00
Steffen SchmitzandGitHub d9c077ea56 chore: add BUILD_ID to otel setup and exclude health from tracing (#4930)
* chore: add BUILD_ID to otel setup

* chore: remove comment
2025-01-08 13:17:00 +00:00
Hassieb PakzadandGitHub 1ec7325b85 feat(models): lastUsed column to full lookback (#4931) 2025-01-08 14:21:07 +01:00
Steffen SchmitzandGitHub 96b2127f36 chore: reduce number of parsing iterations for observations and traces (#4927)
* chore: reduce number of parsing iterations for observations and traces

* chore: lint
2025-01-08 12:36:37 +00:00
Max DeichmannandGitHub d3c2b8cf27 fix: correctly export categorical scores for trace exports (#4928)
* fix: correctly export categorical scores for trace exports

* push
2025-01-08 11:31:50 +00:00
Hassieb PakzadandGitHub beeef222e4 feat(models): sort models table by lastUsed (#4925) 2025-01-08 12:06:57 +01:00
steffen911 ee376f9cad chore: release v3.6.0 2025-01-07 14:43:35 +01:00
c8936ec68d feat: add CLICKHOUSE_DB support (#4911)
* take CLICKHOUSE_DB environment variable into account

Fixes: #4888
Signed-off-by: Aleksandar Pesic <peske.nis@gmail.com>

* chore: cleanup env usage

* chore: extend env.prod.example

---------

Signed-off-by: Aleksandar Pesic <peske.nis@gmail.com>
Co-authored-by: Aleksandar Pesic <peske.nis@gmail.com>
2025-01-07 13:18:03 +00:00
Max DeichmannandGitHub 8d8170c62c security: prevent prototype pollution in dataset compare view (#4910) 2025-01-07 12:15:33 +00:00
Max DeichmannandGitHub 72e1a43192 security: upgrade nanoid (#4909) 2025-01-07 11:47:31 +00:00
Hassieb PakzadandGitHub a842625bd1 chore(media): increase rate-limit to use ingestion budget (#4907) 2025-01-07 11:42:07 +01:00
Max DeichmannandGitHub 890a542759 fix: do not error on 403 by model provider for eval executions (#4905)
* fix: do not error on 403 by model provider

* push
2025-01-07 10:23:58 +00:00
Steffen SchmitzandGitHub 1e8af5b821 chore: move sessions, traces, comments router logs to logger (#4902)
* chore: move sessions router logs to logger

* chore: adjust traces and comments
2025-01-07 07:45:44 +00:00
Hassieb PakzadandGitHub d3f3f354db fix(models): allow string tokenizer config (#4898) 2025-01-06 16:24:49 +01:00
steffen911 18a7cb6684 chore: release v3.5.3 2025-01-06 13:43:19 +01:00
Steffen SchmitzandGitHub 2d70ca574e chore: skip existence check for score deletion (#4896)
* chore: skip existence check for score deletion

* chore: lint
2025-01-06 11:03:51 +00:00
Steffen SchmitzandGitHub 1c980c78dd fix: use singular unit in clickhouse datediff for 23.x support (#4894) 2025-01-06 10:35:53 +00:00
Marc KlingenGitHubMax Deichmanndependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Steffen SchmitzHassieb Pakzad
5170718aad fix(prompt-experiments): do not restrict to evalModelsOnly in order to use anthropic, bedrock, vertex (#4872)
* fix(prompt-experiments): do not restrict to evalModelsOnly in order to use anthropic, bedrock, vertex

* chore(cloud): disable deletion of traces via UI as it caused performance issues (#4873)

chore(cloud): disable deletion of traces via the UI as it caused performance issues

* docs: add link to data model documentation to api reference of ingestion endpoint (#4877)

* fix: use database upserts correctly (#4884)

* fix: use database upserts correctly

* fix: use database upserts correctly

* push

* fix: use database upserts correctly

* fix: use database upserts correctly

* push

* fix: use database upserts correctly

* chore(deps): bump next from 14.2.15 to 14.2.21 (#4887)

Bumps [next](https://github.com/vercel/next.js) from 14.2.15 to 14.2.21.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v14.2.15...v14.2.21)

---
updated-dependencies:
- dependency-name: next
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore: release v3.5.2

* chore: remove postgres from ingestion merge (#4467)

* chore: remove postgres from ingestion merge

* chore: remove pg tests

* chore: remove postgres tracing

* remove evalModelsOnly

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Max Deichmann <m.deichmann@tum.de>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Steffen Schmitz <steffen@langfuse.com>
Co-authored-by: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com>
2025-01-06 10:29:57 +00:00
Steffen SchmitzandGitHub a01c27f4cc chore: remove postgres from ingestion merge (#4467)
* chore: remove postgres from ingestion merge

* chore: remove pg tests

* chore: remove postgres tracing
2025-01-06 09:29:26 +00:00
Max Deichmann 51354e6c6d chore: release v3.5.2 2025-01-06 10:34:55 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
10dd50cff0 chore(deps): bump next from 14.2.15 to 14.2.21 (#4887)
Bumps [next](https://github.com/vercel/next.js) from 14.2.15 to 14.2.21.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v14.2.15...v14.2.21)

---
updated-dependencies:
- dependency-name: next
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-06 09:19:47 +00:00
Max DeichmannandGitHub d8783f8698 fix: use database upserts correctly (#4884)
* fix: use database upserts correctly

* fix: use database upserts correctly

* push

* fix: use database upserts correctly

* fix: use database upserts correctly

* push

* fix: use database upserts correctly
2025-01-03 21:45:56 +00:00
Marc KlingenandGitHub 16a572f51d docs: add link to data model documentation to api reference of ingestion endpoint (#4877) 2025-01-02 21:10:26 +00:00
Marc KlingenandGitHub 947641cbbf chore(cloud): disable deletion of traces via UI as it caused performance issues (#4873)
chore(cloud): disable deletion of traces via the UI as it caused performance issues
2025-01-02 19:03:40 +01:00
Marc KlingenandGitHub 249e161473 chore: remove beta label from evaluation menu (#4871) 2025-01-02 15:14:32 +00:00
Marc KlingenandGitHub 90bab4386f fix(ui): improve /trace/:id redirect when trace is accessed while still being processed (#4868) 2025-01-02 13:51:56 +00:00
Max DeichmannandGitHub 33fddb1344 fix: support large amount of traces for sessions in session.byid (#4847) 2024-12-29 21:42:50 +00:00
Hassieb PakzadandGitHub c24dcb068a fix(legacy-ingestion): usageDetails support (#4841) 2024-12-27 10:11:24 +01:00
Max DeichmannandGitHub 2986723254 fix: ensure idemptence of project deletion queue (#4831) 2024-12-24 11:49:29 +00:00
Max DeichmannandGitHub b45b2fd8f8 chore: clean up error message bullmq endpoint (#4826) 2024-12-23 21:09:57 +00:00
Marc Klingen 8936fb4ad9 chore: release v3.5.0 2024-12-23 17:39:13 +01:00
Marc KlingenandGitHub 7d7e25b39c fix: use clickhouse for tracing usage stats (#4825) 2024-12-23 17:07:50 +01:00
Marc KlingenandGitHub 138d2f1c09 feat(ui): switch to github discussions and issues for in-product feedback (#4822) 2024-12-21 14:16:27 +01:00
Steffen SchmitzandGitHub 51595eb15d chore: export core information tables to S3 daily (#4821) 2024-12-21 12:23:10 +00:00
Marc KlingenandGitHub 6ee68cf508 chore: posthog integration out of beta (#4820)
chore: Posthog integration out of Beta
2024-12-21 12:03:30 +00:00
Marc KlingenandGitHub 308513e644 chore(ui): remove dateRangeFilter on prompt metrics table as no longer necessary on CH (#4818)
* chore(ui): remove dateRangeFilter on prompt metrics table as no longer necessary on CH

* fix lint
2024-12-21 11:49:03 +00:00
Marc KlingenandGitHub 155543bc93 feat(ui): enable the creation of a new LLM connection from the LLM selection dropdown (#4819)
feat(ui): allow to create new llm connection from llm selection dropdown
2024-12-21 11:46:34 +00:00
Marc KlingenandGitHub 8f961925d5 feat(ui): add export status table to project settings (#4816) 2024-12-21 04:38:03 +01:00
Marc KlingenandGitHub 7ddc844d7d fix(ui): clarify what applying producation label to a new prompt does (#4812)
* fix(ui): clarify what applying producation label to a new prompt does

* fix lint and copy
2024-12-21 01:54:52 +00:00
Marc KlingenandGitHub 5381c5f19b feat(cloud): improve open/close state of support chat (#4815)
chore(cloud): hide chat by default, show when opened manually or on incoming/unread messages, persist in session storage
2024-12-21 01:40:39 +00:00
Marc Klingen dcd6060665 chore: release v3.4.0 2024-12-20 22:41:08 +01:00
Chris TranandGitHub 0bfba7d690 feat(auth): add AUTH_CUSTOM_CLIENT_AUTH_METHOD to support PKCE code flow (#4810) 2024-12-20 22:34:02 +01:00
Hassieb Pakzad 25b363e1bc chore: fix eslint error 2024-12-20 18:54:34 +01:00
Hassieb PakzadandGitHub 0de37b0437 feat(models): add UI to add all usage types (#4802) 2024-12-20 18:49:44 +01:00
Max DeichmannandGitHub ab5febb9c7 fix: include time filter into user metrics query (#4808)
* fixes: fix user table sorting and filters

* fixes: fix user table sorting and filters

* fixes: fix user table sorting and filters
2024-12-20 15:59:11 +00:00
marliessophieandGitHub 0e5cade9e7 chore(ui): toggle json editor for text prompts (#4807) 2024-12-20 15:22:28 +00:00
Max DeichmannandGitHub 1438e052df fix: fix json parsing of trace / observation i/o (#4805)
* remove only

* push

* remove only
2024-12-20 12:54:47 +00:00
Max DeichmannandGitHub 9917aff8cf fix: add latencies to trace exports (#4804)
* fix: add latencies to trace exports

* remove only
2024-12-20 12:23:52 +00:00
marliessophieandGitHub c915c2b678 chore(ui): move dataset details into popover on runs table (#4801)
* chore(ui): move dataset details into popover on runs table

* push
2024-12-20 11:41:38 +00:00
marliessophieandGitHub 50379bd4b5 feat(datasets): support charts for scores and resource metrics (#4754)
* fixup: dataset run analytics; compare

* refactor: extract common timeseries component

* push

* fix: proper limit and offset settings

* chore: extend route to accept run ids

* eslint

* chore: order chart data ASC

* styles

* push

* push

* style: adjust multi-select component

* push

* push

* push
2024-12-20 10:14:37 +00:00
Marc KlingenandGitHub 8fd93ecff9 fix(cloud): remove unnecessary posthog events (#4798) 2024-12-20 01:59:39 +01:00
Max DeichmannandGitHub 046c6c6125 fix: fix tests (#4797) 2024-12-20 00:04:31 +01:00
Max DeichmannandGitHub ba750a2d55 fix: fix time to first token in trace detail view (#4796) 2024-12-19 23:41:40 +01:00
156 changed files with 5376 additions and 2449 deletions
+2
View File
@@ -101,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
@@ -244,6 +245,7 @@ OTEL_SERVICE_NAME="langfuse"
# CLICKHOUSE_URL=
# CLICKHOUSE_USER=
# CLICKHOUSE_PASSWORD=
# CLICKHOUSE_DB=
# Ingestion
# LANGFUSE_INGESTION_QUEUE_DELAY_MS=
+2
View File
@@ -38,6 +38,8 @@ services:
LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: ${LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT:-http://minio:9000}
LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: ${LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE:-true}
LANGFUSE_S3_MEDIA_UPLOAD_PREFIX: ${LANGFUSE_S3_MEDIA_UPLOAD_PREFIX:-media/}
LANGFUSE_INGESTION_QUEUE_DELAY_MS: ${LANGFUSE_INGESTION_QUEUE_DELAY_MS:-}
LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS: ${LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS:-}
REDIS_HOST: ${REDIS_HOST:-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
REDIS_AUTH: ${REDIS_AUTH:-myredissecret}
+3 -2
View File
@@ -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"
},
@@ -47,7 +47,8 @@
},
"pnpm": {
"overrides": {
"jsonpath-plus": "10.0.7"
"jsonpath-plus": "10.0.7",
"nanoid": "^3.3.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
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "3.3.0",
"version": "3.6.2",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
@@ -84,7 +84,8 @@
"packageManager": "pnpm@9.5.0",
"pnpm": {
"overrides": {
"jsonpath-plus": "10.0.7"
"jsonpath-plus": "10.0.7",
"nanoid": "^3.3.8"
}
}
}
+14 -4
View File
@@ -18,21 +18,31 @@ then
exit 1
fi
# Ensure CLICKHOUSE_DB is set
if [ -z "${CLICKHOUSE_DB}" ]; then
export CLICKHOUSE_DB="default"
fi
# Ensure CLICKHOUSE_CLUSTER_NAME is set
if [ -z "${CLICKHOUSE_CLUSTER_NAME}" ]; then
export CLICKHOUSE_CLUSTER_NAME="default"
fi
# Construct the database URL
if [ "$CLICKHOUSE_CLUSTER_ENABLED" == "false" ] ; then
if [ "$CLICKHOUSE_MIGRATION_SSL" = true ] ; then
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&secure=true&skip_verify=true&x-migrations-table-engine=MergeTree"
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=${CLICKHOUSE_DB}&x-multi-statement=true&secure=true&skip_verify=true&x-migrations-table-engine=MergeTree"
else
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&x-migrations-table-engine=MergeTree"
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=${CLICKHOUSE_DB}&x-multi-statement=true&x-migrations-table-engine=MergeTree"
fi
# Execute the up command
migrate -source file://clickhouse/migrations/unclustered -database "$DATABASE_URL" down
else
if [ "$CLICKHOUSE_MIGRATION_SSL" = true ] ; then
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&secure=true&skip_verify=true&x-cluster-name=default&x-migrations-table-engine=ReplicatedMergeTree"
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=${CLICKHOUSE_DB}&x-multi-statement=true&secure=true&skip_verify=true&x-cluster-name=${CLICKHOUSE_CLUSTER_NAME}&x-migrations-table-engine=ReplicatedMergeTree"
else
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&x-cluster-name=default&x-migrations-table-engine=ReplicatedMergeTree"
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=${CLICKHOUSE_DB}&x-multi-statement=true&x-cluster-name=${CLICKHOUSE_CLUSTER_NAME}&x-migrations-table-engine=ReplicatedMergeTree"
fi
# Execute the up command
+7 -2
View File
@@ -12,11 +12,16 @@ then
exit 1
fi
# Ensure CLICKHOUSE_DB is set
if [ -z "${CLICKHOUSE_DB}" ]; then
export CLICKHOUSE_DB="default"
fi
# Construct the database URL
if [ "$CLICKHOUSE_MIGRATION_SSL" = true ] ; then
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&secure=true&skip_verify=true&x-migrations-table-engine=MergeTree"
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=${CLICKHOUSE_DB}&x-multi-statement=true&secure=true&skip_verify=true&x-migrations-table-engine=MergeTree"
else
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&x-migrations-table-engine=MergeTree"
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=${CLICKHOUSE_DB}&x-multi-statement=true&x-migrations-table-engine=MergeTree"
fi
# Execute the drop command
migrate -source file://clickhouse/migrations -database "$DATABASE_URL" drop
+14 -4
View File
@@ -18,21 +18,31 @@ then
exit 1
fi
# Ensure CLICKHOUSE_DB is set
if [ -z "${CLICKHOUSE_DB}" ]; then
export CLICKHOUSE_DB="default"
fi
# Ensure CLICKHOUSE_CLUSTER_NAME is set
if [ -z "${CLICKHOUSE_CLUSTER_NAME}" ]; then
export CLICKHOUSE_CLUSTER_NAME="default"
fi
# Construct the database URL
if [ "$CLICKHOUSE_CLUSTER_ENABLED" == "false" ] ; then
if [ "$CLICKHOUSE_MIGRATION_SSL" = true ] ; then
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&secure=true&skip_verify=true&x-migrations-table-engine=MergeTree"
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=${CLICKHOUSE_DB}&x-multi-statement=true&secure=true&skip_verify=true&x-migrations-table-engine=MergeTree"
else
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&x-migrations-table-engine=MergeTree"
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=${CLICKHOUSE_DB}&x-multi-statement=true&x-migrations-table-engine=MergeTree"
fi
# Execute the up command
migrate -source file://clickhouse/migrations/unclustered -database "$DATABASE_URL" up
else
if [ "$CLICKHOUSE_MIGRATION_SSL" = true ] ; then
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&secure=true&skip_verify=true&x-cluster-name=default&x-migrations-table-engine=ReplicatedMergeTree"
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=${CLICKHOUSE_DB}&x-multi-statement=true&secure=true&skip_verify=true&x-cluster-name=${CLICKHOUSE_CLUSTER_NAME}&x-migrations-table-engine=ReplicatedMergeTree"
else
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&x-cluster-name=default&x-migrations-table-engine=ReplicatedMergeTree"
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=${CLICKHOUSE_DB}&x-multi-statement=true&x-cluster-name=${CLICKHOUSE_CLUSTER_NAME}&x-migrations-table-engine=ReplicatedMergeTree"
fi
# Execute the up command
+2 -1
View File
@@ -121,7 +121,8 @@
},
"pnpm": {
"overrides": {
"jsonpath-plus": "10.0.7"
"jsonpath-plus": "10.0.7",
"nanoid": "^3.3.8"
}
}
}
@@ -479,6 +479,13 @@ export type Prompt = {
tags: Generated<string[]>;
labels: Generated<string[]>;
};
export type QueueBackUp = {
id: string;
project_id: string | null;
queue_name: string;
content: unknown;
created_at: Generated<Timestamp>;
};
export type Score = {
id: string;
timestamp: Generated<Timestamp>;
@@ -628,6 +635,7 @@ export type DB = {
project_memberships: ProjectMembership;
projects: Project;
prompts: Prompt;
queue_backups: QueueBackUp;
score_configs: ScoreConfig;
scores: Score;
Session: Session;
@@ -0,0 +1,13 @@
-- CreateTable
CREATE TABLE "queue_backups" (
"id" TEXT NOT NULL,
"project_id" TEXT,
"queue_name" TEXT NOT NULL,
"content" JSONB NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "queue_backups_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "traces" ADD CONSTRAINT "traces_session_id_project_id_fkey" FOREIGN KEY ("session_id", "project_id") REFERENCES "trace_sessions"("id", "project_id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- DropForeignKey
ALTER TABLE "traces" DROP CONSTRAINT "traces_session_id_project_id_fkey";
+23 -14
View File
@@ -272,7 +272,6 @@ model TraceSession {
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
bookmarked Boolean @default(false)
public Boolean @default(false)
traces Trace[]
@@id([id, projectId])
@@index([projectId])
@@ -284,25 +283,24 @@ model TraceSession {
// Update TraceView below when making changes to this model!
model Trace {
id String @id @default(cuid())
externalId String? @map("external_id")
timestamp DateTime @default(now())
id String @id @default(cuid())
externalId String? @map("external_id")
timestamp DateTime @default(now())
name String?
userId String? @map("user_id")
userId String? @map("user_id")
metadata Json?
release String?
version String?
projectId String @map("project_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
public Boolean @default(false)
bookmarked Boolean @default(false)
tags String[] @default([])
projectId String @map("project_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
public Boolean @default(false)
bookmarked Boolean @default(false)
tags String[] @default([])
input Json?
output Json?
sessionId String? @map("session_id")
session TraceSession? @relation(fields: [sessionId, projectId], references: [id, projectId])
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
sessionId String? @map("session_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
@@index([projectId, timestamp])
@@index([sessionId])
@@ -1017,3 +1015,14 @@ model ObservationMedia {
@@index([projectId, observationId])
@@map("observation_media")
}
model QueueBackUp {
id String @id @default(cuid())
projectId String? @map("project_id")
queueName String @map("queue_name")
content Json
createdAt DateTime @default(now()) @map("created_at")
@@map("queue_backups")
}
+6
View File
@@ -29,6 +29,8 @@ const EnvSchema = z.object({
LANGFUSE_CACHE_PROMPT_ENABLED: z.enum(["true", "false"]).default("false"),
LANGFUSE_CACHE_PROMPT_TTL_SECONDS: z.coerce.number().default(60 * 60),
CLICKHOUSE_URL: z.string().url(),
CLICKHOUSE_CLUSTER_NAME: z.string().default("default"),
CLICKHOUSE_DB: z.string().default("default"),
CLICKHOUSE_USER: z.string(),
CLICKHOUSE_PASSWORD: z.string(),
LANGFUSE_SDK_CI_SYNC_PROCESSING_ENABLED: z
@@ -66,6 +68,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> =
+13 -1
View File
@@ -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 ?? [] };
}
@@ -10,7 +10,7 @@ export const clickhouseClient = (opts?: NodeClickHouseClientConfigOptions) =>
url: env.CLICKHOUSE_URL,
username: env.CLICKHOUSE_USER,
password: env.CLICKHOUSE_PASSWORD,
database: "default",
database: env.CLICKHOUSE_DB,
clickhouse_settings: {
async_insert: 1,
wait_for_async_insert: 1, // if disabled, we won't get errors from clickhouse
+2
View File
@@ -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";
@@ -44,6 +45,7 @@ export * from "./logger";
export * from "./queries";
export * from "./repositories";
export * from "./redis/evalExecutionQueue";
export * from "./services/sessions-ui-table-service";
// test utils
export * from "./test-utils";
@@ -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) {
+2
View File
@@ -118,6 +118,7 @@ export enum QueueName {
ExperimentCreate = "experiment-create-queue",
PostHogIntegrationQueue = "posthog-integration-queue",
PostHogIntegrationProcessingQueue = "posthog-integration-processing-queue",
CoreDataS3ExportQueue = "core-data-s3-export-queue",
}
export enum QueueJobs {
@@ -134,6 +135,7 @@ export enum QueueJobs {
ExperimentCreateJob = "experiment-create-job",
PostHogIntegrationJob = "posthog-integration-job",
PostHogIntegrationProcessingJob = "posthog-integration-processing-job",
CoreDataS3ExportJob = "core-data-s3-export-job",
}
export type TQueueJobTypes = {
@@ -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;
}
}
@@ -12,6 +12,7 @@ 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) {
@@ -41,6 +42,8 @@ export function getQueue(queueName: QueueName): Queue | null {
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`);
@@ -418,7 +418,7 @@ export const getObservationLatencies = async (
// Skipping FINAL here, as the quantiles are approximate to begin with.
const query = `
SELECT
quantiles(0.5, 0.9, 0.95, 0.99)(date_diff('milliseconds', o.start_time, o.end_time)) as quantiles,
quantiles(0.5, 0.9, 0.95, 0.99)(date_diff('millisecond', o.start_time, o.end_time)) as quantiles,
name
FROM observations o
${chFilter.find((f) => f.clickhouseTable === "traces") ? "LEFT JOIN traces t ON o.trace_id = t.id AND o.project_id = t.project_id" : ""}
@@ -465,7 +465,7 @@ export const getTracesLatencies = async (
select o.trace_id,
t.name,
o.project_id,
date_diff('milliseconds', min(o.start_time), coalesce(max(o.end_time), max(o.start_time))) as duration
date_diff('millisecond', min(o.start_time), coalesce(max(o.end_time), max(o.start_time))) as duration
FROM traces t
JOIN observations o
ON o.trace_id = t.id AND o.project_id = t.project_id
@@ -521,7 +521,7 @@ export const getModelLatenciesOverTime = async (
SELECT
${selectTimeseriesColumn(groupBy, "o.start_time", "start_time_bucket")},
provided_model_name,
quantiles(0.5, 0.75, 0.9, 0.95, 0.99)(date_diff('milliseconds', o.start_time, o.end_time)) as quantiles
quantiles(0.5, 0.75, 0.9, 0.95, 0.99)(date_diff('millisecond', o.start_time, o.end_time)) as quantiles
FROM observations o
${traceFilter ? "JOIN traces t ON o.trace_id = t.id AND o.project_id = t.project_id" : ""}
WHERE project_id = {projectId: String}
@@ -540,8 +540,8 @@ const getObservationsTableInternal = async <T>(
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"`;
if(isNull(end_time), NULL, date_diff('millisecond', start_time, end_time)) as latency,
if(isNull(completion_start_time), NULL, date_diff('millisecond', start_time, completion_start_time)) as "time_to_first_token"`;
const { projectId, filter, selectIOAndMetadata, limit, offset, orderBy } =
opts;
@@ -750,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,
@@ -966,7 +1010,7 @@ export const getObservationMetricsForPrompts = async (
end_time,
usage_details,
cost_details,
dateDiff('milliseconds', start_time, end_time) AS latency_ms
dateDiff('millisecond', start_time, end_time) AS latency_ms
FROM observations
FINAL
WHERE (type = 'GENERATION')
@@ -1029,7 +1073,7 @@ export const getLatencyAndTotalCostForObservations = async (
SELECT
id,
cost_details['total'] AS total_cost,
dateDiff('milliseconds', start_time, end_time) AS latency_ms
dateDiff('millisecond', start_time, end_time) AS latency_ms
FROM observations FINAL
WHERE project_id = {projectId: String}
AND id IN ({observationIds: Array(String)})
@@ -1061,7 +1105,7 @@ export const getLatencyAndTotalCostForObservationsByTraces = async (
SELECT
trace_id,
sumMap(cost_details)['total'] AS total_cost,
dateDiff('milliseconds', min(start_time), max(end_time)) AS latency_ms
dateDiff('millisecond', min(start_time), max(end_time)) AS latency_ms
FROM observations FINAL
WHERE project_id = {projectId: String}
AND trace_id IN ({traceIds: Array(String)})
@@ -1181,12 +1225,12 @@ export const getGenerationsForPostHog = async (
o.start_time as start_time,
o.id as id,
o.total_cost as total_cost,
if(isNull(completion_start_time), NULL, date_diff('milliseconds', start_time, completion_start_time)) as time_to_first_token,
if(isNull(completion_start_time), NULL, date_diff('millisecond', start_time, completion_start_time)) as time_to_first_token,
o.usage_details['total'] as input_tokens,
o.usage_details['output'] as output_tokens,
o.cost_details['total'] as total_tokens,
o.project_id as project_id,
if(isNull(end_time), NULL, date_diff('milliseconds', start_time, end_time) / 1000) as latency,
if(isNull(end_time), NULL, date_diff('millisecond', start_time, end_time) / 1000) as latency,
o.provided_model_name as model,
o.level as level,
o.version as version,
@@ -9,7 +9,6 @@ import Decimal from "decimal.js";
import { parseClickhouseUTCDateTimeFormat } from "./clickhouse";
import { ObservationRecordReadType } from "./definitions";
import { parseJsonPrioritised } from "../../utils/json";
import { jsonSchema } from "../../utils/zod";
export const convertObservationToView = (
record: ObservationRecordReadType,
@@ -62,15 +61,22 @@ export const convertObservation = (
? parseClickhouseUTCDateTimeFormat(record.end_time)
: null,
name: record.name ?? null,
metadata: record.metadata,
metadata:
record.metadata &&
Object.fromEntries(
Object.entries(record.metadata ?? {}).map(([key, val]) => [
key,
val && parseJsonPrioritised(val),
]),
),
level: record.level as ObservationLevel,
statusMessage: record.status_message ?? null,
version: record.version ?? null,
input: (record.input
? jsonSchema.parse(parseJsonPrioritised(record.input))
? parseJsonPrioritised(record.input)
: null) as Prisma.JsonValue | null,
output: (record.output
? jsonSchema.parse(parseJsonPrioritised(record.output))
? parseJsonPrioritised(record.output)
: null) as Prisma.JsonValue | null,
modelParameters: record.model_parameters
? JSON.parse(record.model_parameters)
@@ -126,10 +132,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,
};
};
@@ -135,13 +135,16 @@ export const upsertScore = async (score: Partial<ScoreRecordReadType>) => {
});
};
export const getScoresForTraces = async (
projectId: string,
traceIds: string[],
timestamp?: Date,
limit?: number,
offset?: number,
) => {
export type GetScoresForTracesProps = {
projectId: string;
traceIds: string[];
timestamp?: Date;
limit?: number;
offset?: number;
};
export const getScoresForTraces = async (props: GetScoresForTracesProps) => {
const { projectId, traceIds, timestamp, limit, offset } = props;
const query = `
select
*
+29 -195
View File
@@ -16,10 +16,7 @@ import {
} from "../queries/clickhouse-sql/clickhouse-filter";
import { TraceRecordReadType } from "./definitions";
import { tracesTableUiColumnDefinitions } from "../../tableDefinitions/mapTracesTable";
import { OrderByState } from "../../interfaces/orderBy";
import { orderByToClickhouseSql } from "../queries/clickhouse-sql/orderby-factory";
import { UiColumnMapping } from "../../tableDefinitions";
import { sessionCols } from "../../tableDefinitions/mapSessionTable";
import { convertDateToClickhouseDateTime } from "../clickhouse/client";
import { convertClickhouseToDomain } from "./traces_converters";
import { clickhouseSearchCondition } from "../queries/clickhouse-sql/search";
@@ -383,196 +380,6 @@ export const getTracesGroupedByTags = async (props: GroupedTracesQueryProp) => {
return rows;
};
export type SessionDataReturnType = {
session_id: string;
max_timestamp: string;
min_timestamp: string;
trace_ids: string[];
user_ids: string[];
trace_count: number;
trace_tags: string[];
total_observations: number;
duration: number;
session_usage_details: Record<string, number>;
session_cost_details: Record<string, number>;
session_input_cost: string;
session_output_cost: string;
session_total_cost: string;
session_input_usage: string;
session_output_usage: string;
session_total_usage: string;
};
export const getSessionsTableCount = async (props: {
projectId: string;
filter: FilterState;
orderBy?: OrderByState;
limit?: number;
page?: number;
}) => {
const rows = await getSessionsTableGeneric<{ count: string }>({
select: `
count(session_id) as count
`,
projectId: props.projectId,
filter: props.filter,
orderBy: props.orderBy,
limit: props.limit,
page: props.page,
});
return rows.length > 0 ? Number(rows[0].count) : 0;
};
export const getSessionsTable = async (props: {
projectId: string;
filter: FilterState;
orderBy?: OrderByState;
limit?: number;
page?: number;
}) => {
const rows = await getSessionsTableGeneric<SessionDataReturnType>({
select: `
session_id,
max_timestamp,
min_timestamp,
trace_ids,
user_ids,
trace_count,
trace_tags,
total_observations,
duration,
session_usage_details,
session_cost_details,
session_input_cost,
session_output_cost,
session_total_cost,
session_input_usage,
session_output_usage,
session_total_usage
`,
projectId: props.projectId,
filter: props.filter,
orderBy: props.orderBy,
limit: props.limit,
page: props.page,
});
return rows;
};
export type FetchSessionsTableProps = {
select: string;
projectId: string;
filter: FilterState;
searchQuery?: string;
orderBy?: OrderByState;
limit?: number;
page?: number;
};
const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
const { select, projectId, filter, orderBy, limit, page } = props;
const { tracesFilter, scoresFilter, observationsFilter } =
getProjectIdDefaultFilter(projectId, { tracesPrefix: "s" });
tracesFilter.push(...createFilterFromFilterState(filter, sessionCols));
const tracesFilterRes = tracesFilter.apply();
const scoresAvgFilterRes = scoresFilter.apply();
const observationsStatsRes = observationsFilter.apply();
const traceTimestampFilter: DateTimeFilter | undefined = tracesFilter.find(
(f) =>
f.field === "min_timestamp" &&
(f.operator === ">=" || f.operator === ">"),
) as DateTimeFilter | undefined;
const singleTraceFilter = traceTimestampFilter
? new FilterList([
new DateTimeFilter({
clickhouseTable: "traces",
field: "timestamp",
operator: traceTimestampFilter.operator,
value: traceTimestampFilter.value,
}),
]).apply()
: undefined;
const query = `
WITH observations_agg AS (
SELECT o.trace_id,
count(*) as obs_count,
min(o.start_time) as min_start_time,
max(o.end_time) as max_end_time,
sumMap(usage_details) as sum_usage_details,
sumMap(cost_details) as sum_cost_details,
anyLast(project_id) as project_id
FROM observations o FINAL
WHERE o.project_id = {projectId: String}
${traceTimestampFilter ? `AND o.start_time >= {observationsStartTime: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}` : ""}
GROUP BY o.trace_id
),
session_data AS (
SELECT
t.session_id,
anyLast(t.project_id) as project_id,
max(t.timestamp) as max_timestamp,
min(t.timestamp) as min_timestamp,
groupArray(t.id) AS trace_ids,
groupUniqArray(t.user_id) AS user_ids,
count(*) as trace_count,
groupUniqArrayArray(t.tags) as trace_tags,
-- Aggregate observations data at session level
sum(o.obs_count) as total_observations,
date_diff('milliseconds', min(min_start_time), max(max_end_time)) as duration,
sumMap(o.sum_usage_details) as session_usage_details,
sumMap(o.sum_cost_details) as session_cost_details,
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'input') > 0, sumMap(o.sum_cost_details)))) as session_input_cost,
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, sumMap(o.sum_cost_details)))) as session_output_cost,
sumMap(o.sum_cost_details)['total'] as session_total_cost,
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'input') > 0, sumMap(o.sum_usage_details)))) as session_input_usage,
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, sumMap(o.sum_usage_details)))) as session_output_usage,
sumMap(o.sum_usage_details)['total'] as session_total_usage
FROM traces t FINAL
LEFT JOIN observations_agg o
ON t.id = o.trace_id AND t.project_id = o.project_id
WHERE t.session_id IS NOT NULL
AND t.project_id = {projectId: String}
${singleTraceFilter?.query ? ` AND ${singleTraceFilter.query}` : ""}
GROUP BY t.session_id
)
SELECT ${select}
FROM session_data s
WHERE ${tracesFilterRes.query ? tracesFilterRes.query : ""}
${orderByToClickhouseSql(orderBy ?? null, sessionCols)}
${limit !== undefined && page !== undefined ? `LIMIT {limit: Int32} OFFSET {offset: Int32}` : ""}
`;
const obsStartTimeValue = traceTimestampFilter
? convertDateToClickhouseDateTime(traceTimestampFilter.value)
: null;
const res = await queryClickhouse<T>({
query: query,
params: {
projectId,
limit: limit,
offset: limit && page ? limit * page : 0,
...tracesFilterRes.params,
...observationsStatsRes.params,
...scoresAvgFilterRes.params,
...singleTraceFilter?.params,
...(obsStartTimeValue
? { observationsStartTime: obsStartTimeValue }
: {}),
},
});
return res;
};
export const getTracesIdentifierForSession = async (
projectId: string,
sessionId: string,
@@ -680,10 +487,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 +535,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 +544,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 +564,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 +603,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),
@@ -804,7 +638,7 @@ export const getTracesForPostHog = async (
o.trace_id,
sum(total_cost) as total_cost,
count(*) as observation_count,
date_diff('milliseconds', least(min(start_time), min(end_time)), greatest(max(start_time), max(end_time))) as latency_milliseconds
date_diff('millisecond', least(min(start_time), min(end_time)), greatest(max(start_time), max(end_time))) as latency_milliseconds
FROM observations o FINAL
WHERE o.project_id = {projectId: String}
AND o.start_time >= {minTimestamp: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}
@@ -1,7 +1,8 @@
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";
export const convertTraceDomainToClickhouse = (
trace: Trace,
@@ -43,9 +44,20 @@ export const convertClickhouseToDomain = (
userId: record.user_id ?? null,
sessionId: record.session_id ?? null,
public: record.public,
input: record.input ?? null,
output: record.output ?? null,
metadata: record.metadata,
input: (record.input
? parseJsonPrioritised(record.input)
: null) as Prisma.JsonValue | null,
output: (record.output
? parseJsonPrioritised(record.output)
: null) as Prisma.JsonValue | null,
metadata:
record.metadata &&
Object.fromEntries(
Object.entries(record.metadata ?? {}).map(([key, val]) => [
key,
val && parseJsonPrioritised(val),
]),
),
createdAt: parseClickhouseUTCDateTimeFormat(record.created_at),
updatedAt: parseClickhouseUTCDateTimeFormat(record.updated_at),
externalId: null,
@@ -30,7 +30,7 @@ export interface StorageService {
download(path: string): Promise<string>;
listFiles(prefix: string): Promise<string[]>;
listFiles(prefix: string): Promise<{ file: string; createdAt: Date }[]>;
getSignedUrl(
fileName: string,
@@ -201,7 +201,9 @@ class AzureBlobStorageService implements StorageService {
}
}
public async listFiles(prefix: string): Promise<string[]> {
public async listFiles(
prefix: string,
): Promise<{ file: string; createdAt: Date }[]> {
try {
await this.createContainerIfNotExists();
@@ -209,7 +211,10 @@ class AzureBlobStorageService implements StorageService {
const files = [];
for await (const blob of result) {
if (blob.name.startsWith(prefix)) {
files.push(blob.name);
files.push({
file: blob.name,
createdAt: blob?.properties?.createdOn ?? new Date(),
});
}
}
return files;
@@ -364,7 +369,9 @@ class S3StorageService implements StorageService {
}
}
public async listFiles(prefix: string): Promise<string[]> {
public async listFiles(
prefix: string,
): Promise<{ file: string; createdAt: Date }[]> {
const listCommand = new ListObjectsV2Command({
Bucket: this.bucketName,
Prefix: prefix,
@@ -373,7 +380,11 @@ class S3StorageService implements StorageService {
try {
const response = await this.client.send(listCommand);
return (
response.Contents?.flatMap((file) => (file.Key ? [file.Key] : [])) ?? []
response.Contents?.flatMap((file) =>
file.Key
? [{ file: file.Key, createdAt: file.LastModified ?? new Date() }]
: [],
) ?? []
);
} catch (err) {
logger.error(`Failed to list files from S3 ${prefix}`, err);
@@ -0,0 +1,307 @@
import { OrderByState } from "../../interfaces/orderBy";
import { sessionCols } from "../../tableDefinitions/mapSessionTable";
import { FilterState } from "../../types";
import { convertDateToClickhouseDateTime } from "../clickhouse/client";
import { DateTimeFilter, FilterList, orderByToClickhouseSql } from "../queries";
import {
getProjectIdDefaultFilter,
createFilterFromFilterState,
} from "../queries/clickhouse-sql/factory";
import {
TRACE_TO_OBSERVATIONS_INTERVAL,
queryClickhouse,
} from "../repositories";
export type SessionDataReturnType = {
session_id: string;
max_timestamp: string;
min_timestamp: string;
trace_ids: string[];
user_ids: string[];
trace_count: number;
trace_tags: string[];
};
export type SessionWithMetricsReturnType = SessionDataReturnType & {
total_observations: number;
duration: number;
session_usage_details: Record<string, number>;
session_cost_details: Record<string, number>;
session_input_cost: string;
session_output_cost: string;
session_total_cost: string;
session_input_usage: string;
session_output_usage: string;
session_total_usage: string;
};
export const getSessionsTableCount = async (props: {
projectId: string;
filter: FilterState;
orderBy?: OrderByState;
limit?: number;
page?: number;
}) => {
const rows = await getSessionsTableGeneric<{ count: string }>({
select: "count",
projectId: props.projectId,
filter: props.filter,
orderBy: props.orderBy,
limit: props.limit,
page: props.page,
});
return rows.length > 0 ? Number(rows[0].count) : 0;
};
export const getSessionsTable = async (props: {
projectId: string;
filter: FilterState;
orderBy?: OrderByState;
limit?: number;
page?: number;
}) => {
const rows = await getSessionsTableGeneric<SessionDataReturnType>({
select: "rows",
projectId: props.projectId,
filter: props.filter,
orderBy: props.orderBy,
limit: props.limit,
page: props.page,
});
return rows.map((row) => ({
...row,
trace_count: Number(row.trace_count),
}));
};
export const getSessionsWithMetrics = async (props: {
projectId: string;
filter: FilterState;
orderBy?: OrderByState;
limit?: number;
page?: number;
}) => {
const rows = await getSessionsTableGeneric<SessionWithMetricsReturnType>({
select: "metrics",
projectId: props.projectId,
filter: props.filter,
orderBy: props.orderBy,
limit: props.limit,
page: props.page,
});
return rows.map((row) => ({
...row,
trace_count: Number(row.trace_count),
total_observations: Number(row.total_observations),
}));
};
export type FetchSessionsTableProps = {
select: "count" | "rows" | "metrics";
projectId: string;
filter: FilterState;
searchQuery?: string;
orderBy?: OrderByState;
limit?: number;
page?: number;
};
const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
const { select, projectId, filter, orderBy, limit, page } = props;
let sqlSelect: string;
switch (select) {
case "count":
sqlSelect = "count(session_id) as count";
break;
case "rows":
sqlSelect = `
session_id,
max_timestamp,
min_timestamp,
trace_ids,
user_ids,
trace_count,
trace_tags`;
break;
case "metrics":
sqlSelect = `
session_id,
max_timestamp,
min_timestamp,
trace_ids,
user_ids,
trace_count,
trace_tags,
total_observations,
duration,
session_usage_details,
session_cost_details,
session_input_cost,
session_output_cost,
session_total_cost,
session_input_usage,
session_output_usage,
session_total_usage`;
break;
default:
const exhaustiveCheckDefault: never = select;
throw new Error(`Unknown select type: ${select}`);
}
const { tracesFilter } = getProjectIdDefaultFilter(projectId, {
tracesPrefix: "s",
});
tracesFilter.push(...createFilterFromFilterState(filter, sessionCols));
const tracesFilterRes = tracesFilter.apply();
const traceTimestampFilter: DateTimeFilter | undefined = tracesFilter.find(
(f) =>
f.field === "min_timestamp" &&
(f.operator === ">=" || f.operator === ">"),
) as DateTimeFilter | undefined;
const filters = [];
if (traceTimestampFilter) {
filters.push(
new DateTimeFilter({
clickhouseTable: "traces",
field: "timestamp",
operator: traceTimestampFilter.operator,
value: traceTimestampFilter.value,
}),
);
}
const additionalSingleTraceFilter = tracesFilter.find(
(f) => f.field === "bookmarked" || f.field === "session_id",
);
if (additionalSingleTraceFilter) {
filters.push(additionalSingleTraceFilter);
}
const singleTraceFilter =
filters.length > 0 ? new FilterList(filters).apply() : undefined;
const hasMetricsFilter = tracesFilter.find((f) =>
[
"session_total_cost",
"session_input_cost",
"session_output_cost",
"duration",
"session_total_usage",
"session_output_usage",
"session_input_usage",
].includes(f.field),
);
const selectMetrics = select === "metrics" || hasMetricsFilter;
// We use deduplicated traces and observations CTEs instead of final to be able to use Skip indices in Clickhouse.
const query = `
WITH deduplicated_traces AS (
SELECT * EXCEPT input, output, metadata
FROM traces t
WHERE t.session_id IS NOT NULL
AND t.project_id = {projectId: String}
${singleTraceFilter?.query ? ` AND ${singleTraceFilter.query}` : ""}
ORDER BY event_ts DESC
LIMIT 1 BY id, project_id
),
deduplicated_observations AS (
SELECT *
FROM observations o
WHERE o.project_id = {projectId: String}
${traceTimestampFilter ? `AND o.start_time >= {observationsStartTime: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}` : ""}
AND o.trace_id IN (
SELECT id
FROM deduplicated_traces
)
ORDER BY event_ts DESC
LIMIT 1 BY id, project_id
),
observations_agg AS (
SELECT o.trace_id,
count(*) as obs_count,
min(o.start_time) as min_start_time,
max(o.end_time) as max_end_time,
sumMap(usage_details) as sum_usage_details,
sumMap(cost_details) as sum_cost_details,
anyLast(project_id) as project_id
FROM deduplicated_observations o
WHERE o.project_id = {projectId: String}
${traceTimestampFilter ? `AND o.start_time >= {observationsStartTime: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}` : ""}
GROUP BY o.trace_id
),
session_data AS (
SELECT
t.session_id,
anyLast(t.project_id) as project_id,
max(t.timestamp) as max_timestamp,
min(t.timestamp) as min_timestamp,
groupArray(t.id) AS trace_ids,
groupUniqArray(t.user_id) AS user_ids,
count(*) as trace_count,
groupUniqArrayArray(t.tags) as trace_tags
-- Aggregate observations data at session level
${
selectMetrics
? `
,
sum(o.obs_count) as total_observations,
date_diff('millisecond', min(min_start_time), max(max_end_time)) as duration,
sumMap(o.sum_usage_details) as session_usage_details,
sumMap(o.sum_cost_details) as session_cost_details,
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'input') > 0, sumMap(o.sum_cost_details)))) as session_input_cost,
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, sumMap(o.sum_cost_details)))) as session_output_cost,
sumMap(o.sum_cost_details)['total'] as session_total_cost,
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'input') > 0, sumMap(o.sum_usage_details)))) as session_input_usage,
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, sumMap(o.sum_usage_details)))) as session_output_usage,
sumMap(o.sum_usage_details)['total'] as session_total_usage`
: ""
}
FROM deduplicated_traces t
${
selectMetrics
? `LEFT JOIN observations_agg o
ON t.id = o.trace_id AND t.project_id = o.project_id`
: ""
}
WHERE t.session_id IS NOT NULL
AND t.project_id = {projectId: String}
${singleTraceFilter?.query ? ` AND ${singleTraceFilter.query}` : ""}
GROUP BY t.session_id
)
SELECT ${sqlSelect}
FROM session_data s
WHERE ${tracesFilterRes.query ? tracesFilterRes.query : ""}
${orderByToClickhouseSql(orderBy ?? null, sessionCols)}
${limit !== undefined && page !== undefined ? `LIMIT {limit: Int32} OFFSET {offset: Int32}` : ""}
`;
const obsStartTimeValue = traceTimestampFilter
? convertDateToClickhouseDateTime(traceTimestampFilter.value)
: null;
const res = await queryClickhouse<T>({
query: query,
params: {
projectId,
limit: limit,
offset: limit && page ? limit * page : 0,
...tracesFilterRes.params,
...singleTraceFilter?.params,
...(obsStartTimeValue
? { observationsStartTime: obsStartTimeValue }
: {}),
},
});
return res;
};
@@ -351,7 +351,7 @@ const getTracesTableGeneric = async <T>(props: FetchTracesTableProps) => {
COUNT(*) AS observation_count,
sumMap(usage_details) as usage_details,
SUM(total_cost) AS total_cost,
date_diff('milliseconds', least(min(start_time), min(end_time)), greatest(max(start_time), max(end_time))) as latency_milliseconds,
date_diff('millisecond', least(min(start_time), min(end_time)), greatest(max(start_time), max(end_time))) as latency_milliseconds,
multiIf(
arrayExists(x -> x = 'ERROR', groupArray(level)), 'ERROR',
arrayExists(x -> x = 'WARNING', groupArray(level)), 'WARNING',
@@ -68,7 +68,7 @@ export const observationsTableUiColumnDefinitions: UiColumnMapping[] = [
uiTableId: "timeToFirstToken",
clickhouseTableName: "observations",
clickhouseSelect:
"if(isNull(completion_start_time), NULL, date_diff('milliseconds', start_time, completion_start_time) / 1000)",
"if(isNull(completion_start_time), NULL, date_diff('millisecond', start_time, completion_start_time) / 1000)",
// If we use the default of Decimal64(12), we cannot filter for more than ~40min due to an overflow
clickhouseTypeOverwrite: "Decimal64(3)",
},
@@ -77,7 +77,7 @@ export const observationsTableUiColumnDefinitions: UiColumnMapping[] = [
uiTableId: "latency",
clickhouseTableName: "observations",
clickhouseSelect:
"if(isNull(end_time), NULL, date_diff('milliseconds', start_time, end_time) / 1000)",
"if(isNull(end_time), NULL, date_diff('millisecond', start_time, end_time) / 1000)",
// If we use the default of Decimal64(12), we cannot filter for more than ~40min due to an overflow
clickhouseTypeOverwrite: "Decimal64(3)",
},
@@ -86,7 +86,7 @@ export const observationsTableUiColumnDefinitions: UiColumnMapping[] = [
uiTableId: "tokensPerSecond",
clickhouseTableName: "observations",
clickhouseSelect:
"(arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, usage_details))) / (date_diff('milliseconds', start_time, end_time) / 1000))",
"(arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, usage_details))) / (date_diff('millisecond', start_time, end_time) / 1000))",
},
{
uiTableName: "Input Cost ($)",
@@ -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",
-16
View File
@@ -55,25 +55,9 @@ export const parseJsonPrioritised = (
): z.infer<typeof jsonSchema> | string | undefined => {
try {
const parsedJson = JSON.parse(json);
if (Object.keys(parsedJson).length === 0) {
return parsedJson;
}
const parsedArray = z.array(jsonSchemaNullable).safeParse(parsedJson);
if (parsedArray.success) {
return parsedArray.data;
}
const parsedObject = z.record(jsonSchemaNullable).safeParse(parsedJson);
if (parsedObject.success) {
return parsedObject.data;
}
return jsonSchema.parse(parsedJson);
} catch (error) {
const parsed = jsonSchema.safeParse(json);
return parsed.success ? parsed.data : json;
}
};
+2 -2
View File
@@ -23,18 +23,18 @@ type Json = Root | { [key: string]: JsonNested } | JsonNested[];
// Here, you define the schema recursively
export const jsonSchemaNullable: z.ZodType<JsonNested> = z.lazy(() =>
z.union([
nestedLiteralSchema,
z.array(jsonSchemaNullable),
z.record(jsonSchemaNullable),
nestedLiteralSchema,
]),
);
// Root schema that does not allow nulls at the root level
export const jsonSchema: z.ZodType<Json> = z.lazy(() =>
z.union([
rootLiteralSchema,
z.array(jsonSchemaNullable),
z.record(jsonSchemaNullable),
rootLiteralSchema,
]),
);
+78 -107
View File
@@ -6,6 +6,7 @@ settings:
overrides:
jsonpath-plus: 10.0.7
nanoid: ^3.3.8
importers:
@@ -45,11 +46,11 @@ importers:
specifier: ^1.7.7
version: 1.7.7
next:
specifier: ^14.2.15
version: 14.2.15(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
specifier: ^14.2.21
version: 14.2.21(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
next-auth:
specifier: ^4.24.11
version: 4.24.11(next@14.2.15(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(nodemailer@6.9.15)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
version: 4.24.11(next@14.2.21(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(nodemailer@6.9.15)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
zod:
specifier: ^3.23.8
version: 3.23.8
@@ -211,7 +212,7 @@ importers:
version: 4.17.21
next-auth:
specifier: ^4.24.11
version: 4.24.11(next@14.2.15(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(nodemailer@6.9.15)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
version: 4.24.11(next@14.2.21(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(nodemailer@6.9.15)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
nodemailer:
specifier: ^6.9.15
version: 6.9.15
@@ -368,7 +369,7 @@ importers:
version: 7.19.0(@emotion/react@11.11.4(@types/react@18.2.79)(react@18.2.0))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.2.79)(react@18.2.0))(@types/react@18.2.79)(react@18.2.0))(@mui/material@5.15.19(@emotion/react@11.11.4(@types/react@18.2.79)(react@18.2.0))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.2.79)(react@18.2.0))(@types/react@18.2.79)(react@18.2.0))(@types/react@18.2.79)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mui/system@5.16.7(@emotion/react@11.11.4(@types/react@18.2.79)(react@18.2.0))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.2.79)(react@18.2.0))(@types/react@18.2.79)(react@18.2.0))(@types/react@18.2.79)(react@18.2.0))(@types/react@18.2.79)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@next-auth/prisma-adapter':
specifier: ^1.0.7
version: 1.0.7(@prisma/client@5.22.0(prisma@5.22.0))(next-auth@4.24.11(next@14.2.15(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(nodemailer@6.9.15)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))
version: 1.0.7(@prisma/client@5.22.0(prisma@5.22.0))(next-auth@4.24.11(next@14.2.21(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(nodemailer@6.9.15)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))
'@opentelemetry/api':
specifier: ^1.9.0
version: 1.9.0
@@ -491,7 +492,7 @@ importers:
version: link:../packages/config-typescript
'@sentry/nextjs':
specifier: ^8.39.0
version: 8.39.0(@opentelemetry/core@1.26.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.53.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.26.0(@opentelemetry/api@1.9.0))(next@14.2.15(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)(webpack@5.95.0)
version: 8.39.0(@opentelemetry/core@1.26.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.53.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.26.0(@opentelemetry/api@1.9.0))(next@14.2.21(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)(webpack@5.95.0)
'@t3-oss/env-nextjs':
specifier: ^0.11.1
version: 0.11.1(typescript@5.4.5)(zod@3.23.8)
@@ -512,7 +513,7 @@ importers:
version: 10.45.2(@trpc/server@10.45.2)
'@trpc/next':
specifier: ^10.45.0
version: 10.45.2(@tanstack/react-query@4.36.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.45.2(@trpc/server@10.45.2))(@trpc/react-query@10.45.2(@tanstack/react-query@4.36.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.45.2(@trpc/server@10.45.2))(@trpc/server@10.45.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/server@10.45.2)(next@14.2.15(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
version: 10.45.2(@tanstack/react-query@4.36.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.45.2(@trpc/server@10.45.2))(@trpc/react-query@10.45.2(@tanstack/react-query@4.36.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.45.2(@trpc/server@10.45.2))(@trpc/server@10.45.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/server@10.45.2)(next@14.2.21(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@trpc/react-query':
specifier: ^10.45.0
version: 10.45.2(@tanstack/react-query@4.36.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.45.2(@trpc/server@10.45.2))(@trpc/server@10.45.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -589,14 +590,14 @@ importers:
specifier: ^0.462.0
version: 0.462.0(react@18.2.0)
next:
specifier: ^14.2.15
version: 14.2.15(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
specifier: ^14.2.21
version: 14.2.21(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
next-auth:
specifier: ^4.24.11
version: 4.24.11(next@14.2.15(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(nodemailer@6.9.15)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
version: 4.24.11(next@14.2.21(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(nodemailer@6.9.15)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
next-query-params:
specifier: ^5.0.1
version: 5.0.1(next@14.2.15(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)(use-query-params@2.2.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0))
version: 5.0.1(next@14.2.21(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)(use-query-params@2.2.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0))
next-themes:
specifier: ^0.3.0
version: 0.3.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -742,7 +743,7 @@ importers:
version: 7.12.0(eslint@8.57.0)(typescript@5.4.5)
autoprefixer:
specifier: ^10.4.19
version: 10.4.19(postcss@8.4.47)
version: 10.4.19(postcss@8.4.49)
dotenv-cli:
specifier: ^7.4.2
version: 7.4.2
@@ -761,9 +762,6 @@ importers:
node-mocks-http:
specifier: ^1.14.1
version: 1.14.1
postcss:
specifier: ^8.4.47
version: 8.4.47
prettier:
specifier: ^3.3.3
version: 3.3.3
@@ -2644,62 +2642,62 @@ packages:
'@prisma/client': '>=2.26.0 || >=3'
next-auth: ^4
'@next/env@14.2.15':
resolution: {integrity: sha512-S1qaj25Wru2dUpcIZMjxeMVSwkt8BK4dmWHHiBuRstcIyOsMapqT4A4jSB6onvqeygkSSmOkyny9VVx8JIGamQ==}
'@next/env@14.2.21':
resolution: {integrity: sha512-lXcwcJd5oR01tggjWJ6SrNNYFGuOOMB9c251wUNkjCpkoXOPkDeF/15c3mnVlBqrW4JJXb2kVxDFhC4GduJt2A==}
'@next/eslint-plugin-next@14.2.15':
resolution: {integrity: sha512-pKU0iqKRBlFB/ocOI1Ip2CkKePZpYpnw5bEItEkuZ/Nr9FQP1+p7VDWr4VfOdff4i9bFmrOaeaU1bFEyAcxiMQ==}
'@next/swc-darwin-arm64@14.2.15':
resolution: {integrity: sha512-Rvh7KU9hOUBnZ9TJ28n2Oa7dD9cvDBKua9IKx7cfQQ0GoYUwg9ig31O2oMwH3wm+pE3IkAQ67ZobPfEgurPZIA==}
'@next/swc-darwin-arm64@14.2.21':
resolution: {integrity: sha512-HwEjcKsXtvszXz5q5Z7wCtrHeTTDSTgAbocz45PHMUjU3fBYInfvhR+ZhavDRUYLonm53aHZbB09QtJVJj8T7g==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-x64@14.2.15':
resolution: {integrity: sha512-5TGyjFcf8ampZP3e+FyCax5zFVHi+Oe7sZyaKOngsqyaNEpOgkKB3sqmymkZfowy3ufGA/tUgDPPxpQx931lHg==}
'@next/swc-darwin-x64@14.2.21':
resolution: {integrity: sha512-TSAA2ROgNzm4FhKbTbyJOBrsREOMVdDIltZ6aZiKvCi/v0UwFmwigBGeqXDA97TFMpR3LNNpw52CbVelkoQBxA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/swc-linux-arm64-gnu@14.2.15':
resolution: {integrity: sha512-3Bwv4oc08ONiQ3FiOLKT72Q+ndEMyLNsc/D3qnLMbtUYTQAmkx9E/JRu0DBpHxNddBmNT5hxz1mYBphJ3mfrrw==}
'@next/swc-linux-arm64-gnu@14.2.21':
resolution: {integrity: sha512-0Dqjn0pEUz3JG+AImpnMMW/m8hRtl1GQCNbO66V1yp6RswSTiKmnHf3pTX6xMdJYSemf3O4Q9ykiL0jymu0TuA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-arm64-musl@14.2.15':
resolution: {integrity: sha512-k5xf/tg1FBv/M4CMd8S+JL3uV9BnnRmoe7F+GWC3DxkTCD9aewFRH1s5rJ1zkzDa+Do4zyN8qD0N8c84Hu96FQ==}
'@next/swc-linux-arm64-musl@14.2.21':
resolution: {integrity: sha512-Ggfw5qnMXldscVntwnjfaQs5GbBbjioV4B4loP+bjqNEb42fzZlAaK+ldL0jm2CTJga9LynBMhekNfV8W4+HBw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-x64-gnu@14.2.15':
resolution: {integrity: sha512-kE6q38hbrRbKEkkVn62reLXhThLRh6/TvgSP56GkFNhU22TbIrQDEMrO7j0IcQHcew2wfykq8lZyHFabz0oBrA==}
'@next/swc-linux-x64-gnu@14.2.21':
resolution: {integrity: sha512-uokj0lubN1WoSa5KKdThVPRffGyiWlm/vCc/cMkWOQHw69Qt0X1o3b2PyLLx8ANqlefILZh1EdfLRz9gVpG6tg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-linux-x64-musl@14.2.15':
resolution: {integrity: sha512-PZ5YE9ouy/IdO7QVJeIcyLn/Rc4ml9M2G4y3kCM9MNf1YKvFY4heg3pVa/jQbMro+tP6yc4G2o9LjAz1zxD7tQ==}
'@next/swc-linux-x64-musl@14.2.21':
resolution: {integrity: sha512-iAEBPzWNbciah4+0yI4s7Pce6BIoxTQ0AGCkxn/UBuzJFkYyJt71MadYQkjPqCQCJAFQ26sYh7MOKdU+VQFgPg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-win32-arm64-msvc@14.2.15':
resolution: {integrity: sha512-2raR16703kBvYEQD9HNLyb0/394yfqzmIeyp2nDzcPV4yPjqNUG3ohX6jX00WryXz6s1FXpVhsCo3i+g4RUX+g==}
'@next/swc-win32-arm64-msvc@14.2.21':
resolution: {integrity: sha512-plykgB3vL2hB4Z32W3ktsfqyuyGAPxqwiyrAi2Mr8LlEUhNn9VgkiAl5hODSBpzIfWweX3er1f5uNpGDygfQVQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/swc-win32-ia32-msvc@14.2.15':
resolution: {integrity: sha512-fyTE8cklgkyR1p03kJa5zXEaZ9El+kDNM5A+66+8evQS5e/6v0Gk28LqA0Jet8gKSOyP+OTm/tJHzMlGdQerdQ==}
'@next/swc-win32-ia32-msvc@14.2.21':
resolution: {integrity: sha512-w5bacz4Vxqrh06BjWgua3Yf7EMDb8iMcVhNrNx8KnJXt8t+Uu0Zg4JHLDL/T7DkTCEEfKXO/Er1fcfWxn2xfPA==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@next/swc-win32-x64-msvc@14.2.15':
resolution: {integrity: sha512-SzqGbsLsP9OwKNUG9nekShTwhj6JSB9ZLMWQ8g1gG6hdE5gQLncbnbymrwy2yVmH9nikSLYRYxYMFu78Ggp7/g==}
'@next/swc-win32-x64-msvc@14.2.21':
resolution: {integrity: sha512-sT6+llIkzpsexGYZq8cjjthRyRGe5cJVhqh12FmlbxHqna6zsDDK8UNaV7g41T6atFHCJUPeLb3uyAwrBwy0NA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -5775,9 +5773,6 @@ packages:
caniuse-lite@1.0.30001599:
resolution: {integrity: sha512-LRAQHZ4yT1+f9LemSMeqdMpMxZcc4RMWdj4tiFe3G8tNkWK+E58g+/tzotb5cU6TbcVJLr4fySiAW7XmxQvZQA==}
caniuse-lite@1.0.30001667:
resolution: {integrity: sha512-7LTwJjcRkzKFmtqGsibMeuXmvFDfZq/nzIjnmgCGzKKRVzjD72selLDK1oPF/Oxzmt4fNcPvTDvGqSDG4tCALw==}
caniuse-lite@1.0.30001680:
resolution: {integrity: sha512-rPQy70G6AGUMnbwS1z6Xg+RkHYPAi18ihs47GH0jcxIG7wArmPgY3XbS2sRdBbxJljp3thdT8BIqv9ccCypiPA==}
@@ -8979,13 +8974,8 @@ packages:
mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
nanoid@3.3.6:
resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@3.3.7:
resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==}
nanoid@3.3.8:
resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
@@ -9038,8 +9028,8 @@ packages:
react: ^16.8 || ^17 || ^18
react-dom: ^16.8 || ^17 || ^18
next@14.2.15:
resolution: {integrity: sha512-h9ctmOokpoDphRvMGnwOJAedT6zKhwqyZML9mDtspgf4Rh3Pn7UTYKqePNoDvhsWBAO5GoPNYshnAUGIazVGmw==}
next@14.2.21:
resolution: {integrity: sha512-rZmLwucLHr3/zfDMYbJXbw0ZeoBpirxkXuvsJbk7UPorvPYZhP7vq7aHbKnU7dQNCYIimRrbB2pp3xmf+wsYUg==}
engines: {node: '>=18.17.0'}
hasBin: true
peerDependencies:
@@ -9521,9 +9511,6 @@ packages:
picocolors@1.0.0:
resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==}
picocolors@1.1.0:
resolution: {integrity: sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -9615,10 +9602,6 @@ packages:
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
engines: {node: ^10 || ^12 || >=14}
postcss@8.4.47:
resolution: {integrity: sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==}
engines: {node: ^10 || ^12 || >=14}
postcss@8.4.49:
resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==}
engines: {node: ^10 || ^12 || >=14}
@@ -11578,7 +11561,7 @@ snapshots:
dependencies:
'@ai-sdk/provider': 0.0.24
eventsource-parser: 1.1.2
nanoid: 3.3.6
nanoid: 3.3.8
secure-json-parse: 2.7.0
optionalDependencies:
zod: 3.23.8
@@ -14332,42 +14315,42 @@ snapshots:
transitivePeerDependencies:
- '@types/react'
'@next-auth/prisma-adapter@1.0.7(@prisma/client@5.22.0(prisma@5.22.0))(next-auth@4.24.11(next@14.2.15(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(nodemailer@6.9.15)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))':
'@next-auth/prisma-adapter@1.0.7(@prisma/client@5.22.0(prisma@5.22.0))(next-auth@4.24.11(next@14.2.21(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(nodemailer@6.9.15)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))':
dependencies:
'@prisma/client': 5.22.0(prisma@5.22.0)
next-auth: 4.24.11(next@14.2.15(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(nodemailer@6.9.15)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
next-auth: 4.24.11(next@14.2.21(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(nodemailer@6.9.15)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@next/env@14.2.15': {}
'@next/env@14.2.21': {}
'@next/eslint-plugin-next@14.2.15':
dependencies:
glob: 10.3.10
'@next/swc-darwin-arm64@14.2.15':
'@next/swc-darwin-arm64@14.2.21':
optional: true
'@next/swc-darwin-x64@14.2.15':
'@next/swc-darwin-x64@14.2.21':
optional: true
'@next/swc-linux-arm64-gnu@14.2.15':
'@next/swc-linux-arm64-gnu@14.2.21':
optional: true
'@next/swc-linux-arm64-musl@14.2.15':
'@next/swc-linux-arm64-musl@14.2.21':
optional: true
'@next/swc-linux-x64-gnu@14.2.15':
'@next/swc-linux-x64-gnu@14.2.21':
optional: true
'@next/swc-linux-x64-musl@14.2.15':
'@next/swc-linux-x64-musl@14.2.21':
optional: true
'@next/swc-win32-arm64-msvc@14.2.15':
'@next/swc-win32-arm64-msvc@14.2.21':
optional: true
'@next/swc-win32-ia32-msvc@14.2.15':
'@next/swc-win32-ia32-msvc@14.2.21':
optional: true
'@next/swc-win32-x64-msvc@14.2.15':
'@next/swc-win32-x64-msvc@14.2.21':
optional: true
'@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1':
@@ -16216,7 +16199,7 @@ snapshots:
'@sentry/types': 8.39.0
'@sentry/utils': 8.39.0
'@sentry/nextjs@8.39.0(@opentelemetry/core@1.26.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.53.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.26.0(@opentelemetry/api@1.9.0))(next@14.2.15(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)(webpack@5.95.0)':
'@sentry/nextjs@8.39.0(@opentelemetry/core@1.26.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.53.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.26.0(@opentelemetry/api@1.9.0))(next@14.2.21(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)(webpack@5.95.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/instrumentation-http': 0.53.0(@opentelemetry/api@1.9.0)
@@ -16232,7 +16215,7 @@ snapshots:
'@sentry/vercel-edge': 8.39.0
'@sentry/webpack-plugin': 2.22.6(webpack@5.95.0)
chalk: 3.0.0
next: 14.2.15(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
next: 14.2.21(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
resolve: 1.22.8
rollup: 3.29.5
stacktrace-parser: 0.1.10
@@ -16817,13 +16800,13 @@ snapshots:
dependencies:
'@trpc/server': 10.45.2
'@trpc/next@10.45.2(@tanstack/react-query@4.36.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.45.2(@trpc/server@10.45.2))(@trpc/react-query@10.45.2(@tanstack/react-query@4.36.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.45.2(@trpc/server@10.45.2))(@trpc/server@10.45.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/server@10.45.2)(next@14.2.15(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
'@trpc/next@10.45.2(@tanstack/react-query@4.36.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.45.2(@trpc/server@10.45.2))(@trpc/react-query@10.45.2(@tanstack/react-query@4.36.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.45.2(@trpc/server@10.45.2))(@trpc/server@10.45.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/server@10.45.2)(next@14.2.21(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@tanstack/react-query': 4.36.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@trpc/client': 10.45.2(@trpc/server@10.45.2)
'@trpc/react-query': 10.45.2(@tanstack/react-query@4.36.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.45.2(@trpc/server@10.45.2))(@trpc/server@10.45.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@trpc/server': 10.45.2
next: 14.2.15(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
next: 14.2.21(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
@@ -17723,7 +17706,7 @@ snapshots:
eventsource-parser: 1.1.2
json-schema: 0.4.0
jsondiffpatch: 0.6.0
nanoid: 3.3.6
nanoid: 3.3.8
secure-json-parse: 2.7.0
zod-to-json-schema: 3.23.2(zod@3.23.8)
optionalDependencies:
@@ -17936,14 +17919,14 @@ snapshots:
asynckit@0.4.0: {}
autoprefixer@10.4.19(postcss@8.4.47):
autoprefixer@10.4.19(postcss@8.4.49):
dependencies:
browserslist: 4.23.0
caniuse-lite: 1.0.30001599
fraction.js: 4.3.7
normalize-range: 0.1.2
picocolors: 1.0.0
postcss: 8.4.47
postcss: 8.4.49
postcss-value-parser: 4.2.0
available-typed-arrays@1.0.7:
@@ -18096,14 +18079,14 @@ snapshots:
browserslist@4.23.0:
dependencies:
caniuse-lite: 1.0.30001667
caniuse-lite: 1.0.30001680
electron-to-chromium: 1.4.710
node-releases: 2.0.14
update-browserslist-db: 1.0.13(browserslist@4.23.0)
browserslist@4.24.0:
dependencies:
caniuse-lite: 1.0.30001667
caniuse-lite: 1.0.30001680
electron-to-chromium: 1.5.33
node-releases: 2.0.18
update-browserslist-db: 1.1.1(browserslist@4.24.0)
@@ -18197,8 +18180,6 @@ snapshots:
caniuse-lite@1.0.30001599: {}
caniuse-lite@1.0.30001667: {}
caniuse-lite@1.0.30001680: {}
ccount@2.0.1: {}
@@ -22287,9 +22268,7 @@ snapshots:
object-assign: 4.1.1
thenify-all: 1.6.0
nanoid@3.3.6: {}
nanoid@3.3.7: {}
nanoid@3.3.8: {}
natural-compare@1.4.0: {}
@@ -22305,13 +22284,13 @@ snapshots:
dependencies:
type-fest: 2.19.0
next-auth@4.24.11(next@14.2.15(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(nodemailer@6.9.15)(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
next-auth@4.24.11(next@14.2.21(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(nodemailer@6.9.15)(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
dependencies:
'@babel/runtime': 7.26.0
'@panva/hkdf': 1.1.1
cookie: 0.7.2
jose: 4.15.5
next: 14.2.15(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
next: 14.2.21(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
oauth: 0.9.15
openid-client: 5.6.5
preact: 10.19.7
@@ -22322,9 +22301,9 @@ snapshots:
optionalDependencies:
nodemailer: 6.9.15
next-query-params@5.0.1(next@14.2.15(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)(use-query-params@2.2.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)):
next-query-params@5.0.1(next@14.2.21(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)(use-query-params@2.2.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)):
dependencies:
next: 14.2.15(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
next: 14.2.21(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react: 18.2.0
tslib: 2.6.3
use-query-params: 2.2.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -22334,27 +22313,27 @@ snapshots:
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
next@14.2.15(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
next@14.2.21(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
dependencies:
'@next/env': 14.2.15
'@next/env': 14.2.21
'@swc/helpers': 0.5.5
busboy: 1.6.0
caniuse-lite: 1.0.30001667
caniuse-lite: 1.0.30001680
graceful-fs: 4.2.11
postcss: 8.4.31
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
styled-jsx: 5.1.1(@babel/core@7.24.3)(babel-plugin-macros@3.1.0)(react@18.2.0)
optionalDependencies:
'@next/swc-darwin-arm64': 14.2.15
'@next/swc-darwin-x64': 14.2.15
'@next/swc-linux-arm64-gnu': 14.2.15
'@next/swc-linux-arm64-musl': 14.2.15
'@next/swc-linux-x64-gnu': 14.2.15
'@next/swc-linux-x64-musl': 14.2.15
'@next/swc-win32-arm64-msvc': 14.2.15
'@next/swc-win32-ia32-msvc': 14.2.15
'@next/swc-win32-x64-msvc': 14.2.15
'@next/swc-darwin-arm64': 14.2.21
'@next/swc-darwin-x64': 14.2.21
'@next/swc-linux-arm64-gnu': 14.2.21
'@next/swc-linux-arm64-musl': 14.2.21
'@next/swc-linux-x64-gnu': 14.2.21
'@next/swc-linux-x64-musl': 14.2.21
'@next/swc-win32-arm64-msvc': 14.2.21
'@next/swc-win32-ia32-msvc': 14.2.21
'@next/swc-win32-x64-msvc': 14.2.21
'@opentelemetry/api': 1.9.0
'@playwright/test': 1.47.2
transitivePeerDependencies:
@@ -22856,8 +22835,6 @@ snapshots:
picocolors@1.0.0: {}
picocolors@1.1.0: {}
picocolors@1.1.1: {}
picomatch@2.3.1: {}
@@ -22931,19 +22908,13 @@ snapshots:
postcss@8.4.31:
dependencies:
nanoid: 3.3.7
nanoid: 3.3.8
picocolors: 1.1.1
source-map-js: 1.2.1
postcss@8.4.47:
dependencies:
nanoid: 3.3.7
picocolors: 1.1.0
source-map-js: 1.2.1
postcss@8.4.49:
dependencies:
nanoid: 3.3.7
nanoid: 3.3.8
picocolors: 1.1.1
source-map-js: 1.2.1
+3
View File
@@ -95,6 +95,9 @@ ARG BUILDPLATFORM
WORKDIR /app
ARG NEXT_PUBLIC_BUILD_ID
ENV BUILD_ID=$NEXT_PUBLIC_BUILD_ID
ENV NODE_ENV production
# Uncomment the following line in case you want to disable telemetry during runtime.
ENV NEXT_TELEMETRY_DISABLED 1
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "3.3.0",
"version": "3.6.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",
@@ -175,7 +175,6 @@
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"node-mocks-http": "^1.14.1",
"postcss": "^8.4.47",
"prettier": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.6",
"tailwindcss": "^3.4.17",
@@ -193,7 +192,8 @@
},
"pnpm": {
"overrides": {
"jsonpath-plus": "10.0.7"
"jsonpath-plus": "10.0.7",
"nanoid": "^3.3.8"
}
}
}
+3
View File
@@ -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.
+1 -1
View File
@@ -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(
@@ -204,6 +204,7 @@ describe("/api/public/observations API Endpoint", () => {
trace_id: traceId,
name: "generation-name",
start_time: new Date("2021-01-01T00:00:00.000Z").getTime(),
event_ts: new Date("2021-01-01T00:00:00.000Z").getTime(),
end_time: new Date("2021-01-01T00:00:00.000Z").getTime(),
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
type: "GENERATION",
@@ -214,6 +215,7 @@ describe("/api/public/observations API Endpoint", () => {
trace_id: traceId,
name: "generation-name",
start_time: new Date("2021-02-01T00:00:00.000Z").getTime(),
event_ts: new Date("2021-02-01T00:00:00.000Z").getTime(),
end_time: new Date("2021-02-01T00:00:00.000Z").getTime(),
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
type: "SPAN",
@@ -223,6 +225,7 @@ describe("/api/public/observations API Endpoint", () => {
trace_id: traceId,
name: "generation-name",
start_time: new Date("2021-03-01T00:00:00.000Z").getTime(),
event_ts: new Date("2021-03-01T00:00:00.000Z").getTime(),
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
type: "EVENT",
});
@@ -231,6 +234,7 @@ describe("/api/public/observations API Endpoint", () => {
trace_id: traceId,
name: "generation-name",
start_time: new Date("2021-04-01T00:00:00.000Z").getTime(),
event_ts: new Date("2021-04-01T00:00:00.000Z").getTime(),
end_time: new Date("2021-04-01T00:00:00.000Z").getTime(),
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
type: "GENERATION",
@@ -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));
@@ -0,0 +1,192 @@
import { v4 } from "uuid";
import { prisma } from "@langfuse/shared/src/db";
import {
createObservation,
createObservationsCh,
createOrgProjectAndApiKey,
createTracesCh,
getSessionsWithMetrics,
} from "@langfuse/shared/src/server";
import { createTrace, getSessionsTable } from "@langfuse/shared/src/server";
describe("trpc.sessions", () => {
describe("GET sessions.all", () => {
it("should GET all session", async () => {
const { projectId } = await createOrgProjectAndApiKey();
const sessionId = v4();
await prisma.traceSession.create({
data: {
id: sessionId,
projectId: projectId,
},
});
const traces = [
createTrace({ session_id: sessionId, project_id: projectId }),
createTrace({ session_id: sessionId, project_id: projectId }),
];
await createTracesCh(traces);
const uiSessions = await getSessionsTable({
projectId: projectId,
filter: [],
orderBy: null,
limit: 10000,
page: 0,
});
expect(uiSessions.length).toBe(1);
expect(uiSessions[0].session_id).toBe(sessionId);
expect(uiSessions[0].trace_count).toBe(2);
expect(uiSessions[0].trace_tags).toEqual(["doe", "john"]);
});
});
it("should GET all session filtered by trace attribute only", async () => {
const { projectId } = await createOrgProjectAndApiKey();
const sessionId = v4();
await prisma.traceSession.create({
data: {
id: sessionId,
projectId: projectId,
},
});
const traces = [
createTrace({
session_id: sessionId,
project_id: projectId,
user_id: "user1",
}),
createTrace({
session_id: sessionId,
project_id: projectId,
user_id: undefined,
}),
];
await createTracesCh(traces);
const uiSessions = await getSessionsTable({
projectId: projectId,
filter: [
{
column: "userIds",
type: "stringOptions",
operator: "any of",
value: ["user1"],
},
],
orderBy: null,
limit: 10000,
page: 0,
});
expect(uiSessions.length).toBe(1);
expect(uiSessions[0].session_id).toBe(sessionId);
expect(uiSessions[0].trace_count).toBe(2);
expect(uiSessions[0].trace_tags).toEqual(["doe", "john"]);
expect(uiSessions[0].user_ids).toEqual(["user1"]);
});
it("should GET metrics for a list of sessions", async () => {
const { projectId } = await createOrgProjectAndApiKey();
const sessionId1 = v4();
const sessionId2 = v4();
await prisma.traceSession.createMany({
data: [
{
id: sessionId1,
projectId: projectId,
},
{
id: sessionId2,
projectId: projectId,
},
],
});
const traces = [
createTrace({
session_id: sessionId1,
project_id: projectId,
user_id: "user1",
}),
createTrace({
session_id: sessionId1,
project_id: projectId,
user_id: "user2",
}),
createTrace({
session_id: sessionId2,
project_id: projectId,
user_id: "user3",
}),
];
const observations = traces.flatMap((trace) => [
createObservation({
trace_id: trace.id,
project_id: projectId,
}),
createObservation({
trace_id: trace.id,
project_id: projectId,
}),
]);
await createObservationsCh(observations);
await createTracesCh(traces);
const sessions = await getSessionsWithMetrics({
projectId: projectId,
filter: [
{
column: "id",
type: "stringOptions",
operator: "any of",
value: [sessionId1, sessionId2],
},
],
});
expect(sessions.length).toBe(2);
// Session 1 checks
const session1 = sessions.find((s) => s.session_id === sessionId1);
expect(session1).toBeDefined();
expect(session1?.trace_count).toBe(2);
expect(session1?.user_ids).toEqual(
expect.arrayContaining(["user1", "user2"]),
);
expect(session1?.trace_tags).toEqual(["doe", "john"]);
expect(session1?.total_observations).toEqual(4);
expect(Number(session1?.session_input_cost)).toBeGreaterThan(0);
expect(Number(session1?.session_output_cost)).toBeGreaterThan(0);
expect(Number(session1?.session_total_cost)).toBeGreaterThan(0);
expect(Number(session1?.session_input_usage)).toBeGreaterThan(0);
expect(Number(session1?.session_output_usage)).toBeGreaterThan(0);
expect(Number(session1?.session_total_usage)).toBeGreaterThan(0);
// Session 2 checks
const session2 = sessions.find((s) => s.session_id === sessionId2);
expect(session2).toBeDefined();
expect(session2?.trace_count).toBe(1);
expect(session2?.user_ids).toEqual(["user3"]);
expect(session2?.trace_tags).toEqual(["doe", "john"]);
expect(session2?.total_observations).toEqual(2);
expect(Number(session2?.session_input_cost)).toBeGreaterThan(0);
expect(Number(session2?.session_output_cost)).toBeGreaterThan(0);
expect(Number(session2?.session_total_cost)).toBeGreaterThan(0);
expect(Number(session2?.session_input_usage)).toBeGreaterThan(0);
expect(Number(session2?.session_output_usage)).toBeGreaterThan(0);
expect(Number(session2?.session_total_usage)).toBeGreaterThan(0);
});
});
@@ -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({
+29 -1
View File
@@ -1,4 +1,4 @@
import { paginationZod } from "@langfuse/shared";
import { paginationZod, parseJsonPrioritised } from "@langfuse/shared";
import { ZodError } from "zod";
// Create test cases
@@ -24,3 +24,31 @@ describe("Pagination Zod Schema", () => {
expect(() => paginationZod.limit.parse("abc")).toThrowError(ZodError);
});
});
describe("parseJsonPrioritised", () => {
it.each([
["test", "test"], // Raw string
['{"hello": "world"}', { hello: "world" }], // Simple object
["[1, 2, 3]", [1, 2, 3]], // Array
['{"nested": {"key": "value"}}', { nested: { key: "value" } }], // Nested object
["[]", []], // Empty array
["{}", {}], // Empty object
['"simple string"', "simple string"], // Quoted string
["42", 42], // Number
["true", true], // Boolean true
["false", false], // Boolean false
["null", "null"], // Null
["", ""], // Empty string
["invalid{json}", "invalid{json}"], // Invalid JSON string
['[null, 123, "abc"]', [null, 123, "abc"]], // Mixed array
[
'{"array": [1, 2], "nested": {"key": "value"}}',
{ array: [1, 2], nested: { key: "value" } },
], // Complex object
])(
"should parse input correctly (%s, %s)",
(input: string, expectedOutput: any) => {
expect(parseJsonPrioritised(input)).toEqual(expectedOutput);
},
);
});
@@ -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) => {
+6 -9
View File
@@ -3,6 +3,7 @@ import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/src/components/ui/select";
@@ -33,15 +34,12 @@ export type ModelParamsContext = {
modelParamsDescription?: string;
};
export const ModelParameters: React.FC<
ModelParamsContext & { evalModelsOnly: boolean }
> = ({
export const ModelParameters: React.FC<ModelParamsContext> = ({
modelParams,
availableProviders,
availableModels,
updateModelParamValue,
setModelParamEnabled,
evalModelsOnly,
formDisabled = false,
modelParamsDescription,
}) => {
@@ -54,11 +52,8 @@ export const ModelParameters: React.FC<
<p className="font-semibold">Model</p>
{availableProviders.length === 0 ? (
<>
<p className="text-xs">
No LLM API key set in project.{" "}
{evalModelsOnly && "For evals, only OpenAI models are supported."}
</p>
<CreateLLMApiKeyDialog evalModelsOnly={evalModelsOnly} />
<p className="text-xs">No LLM API key set in project. </p>
<CreateLLMApiKeyDialog />
</>
) : (
<div className="space-y-4">
@@ -186,6 +181,8 @@ const ModelParamsSelect = ({
{option}
</SelectItem>
))}
<SelectSeparator />
<CreateLLMApiKeyDialog />
</SelectContent>
</Select>
{modelParamsDescription ? (
-1
View File
@@ -87,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: [
+5 -6
View File
@@ -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;
}
+1 -5
View File
@@ -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" />
@@ -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={() => {
+13 -1
View File
@@ -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: {
+137 -218
View File
@@ -1,39 +1,41 @@
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;
lastUsed?: Date | null;
serverResponse: GetModelResult;
};
const modelConfigDescriptions = {
@@ -43,39 +45,47 @@ 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.",
lastUsed: "Start time of the latest generation using this model",
} 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 +93,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 +142,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 +181,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
description: modelConfigDescriptions.tokenizerId,
},
enableHiding: true,
size: 110,
size: 120,
},
{
accessorKey: "config",
@@ -231,7 +191,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");
@@ -240,17 +200,46 @@ export default function ModelTable({ projectId }: { projectId: string }) {
) : null;
},
},
{
accessorKey: "lastUsed",
id: "lastUsed",
header: "Last used",
headerTooltip: {
description: modelConfigDescriptions.lastUsed,
},
enableHiding: true,
size: 120,
cell: ({ row }) => {
const value: Date | null | undefined = row.getValue("lastUsed");
return value?.toLocaleString() ?? "";
},
},
{
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 +253,17 @@ 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,
lastUsed: model.lastUsed,
serverResponse: model,
};
};
@@ -320,76 +305,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
+14 -9
View File
@@ -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 ?? ""}
/>
)}
</>
}
/>
+119 -19
View File
@@ -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
View File
@@ -1 +1 @@
export const VERSION = "v3.3.0";
export const VERSION = "v3.6.2";
@@ -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>
@@ -545,7 +545,6 @@ export const InnerEvalTemplateForm = (props: {
modelParamsDescription:
"Select a model which supports function calling.",
}}
evalModelsOnly
formDisabled={!props.isEditing}
/>
</div>
@@ -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
@@ -682,10 +683,7 @@ export const CreateExperimentsForm = ({
availableProviders,
updateModelParamValue: updateModelParamValue,
setModelParamEnabled,
modelParamsDescription:
"Select a model which supports function calling.",
}}
evalModelsOnly
/>
</Card>
{form.formState.errors.modelConfig && (
@@ -790,13 +788,13 @@ export const CreateExperimentsForm = ({
}
hideClearButton
controlButtons={
<CommandItem
<DropdownMenuItem
onSelect={() => {
window.open(`/project/${projectId}/evals`, "_blank");
}}
>
Manage evaluators
</CommandItem>
</DropdownMenuItem>
}
/>
</FormItem>
@@ -14,7 +14,7 @@ export default function Playground() {
<div className="max-h-full min-h-0 basis-1/4 pr-2">
<div className="grid h-full grid-rows-[minmax(20dvh,max-content),minmax(20dvh,auto)] overflow-auto">
<div className="mb-4 max-h-[80dvh] min-h-[20dvh] overflow-y-auto">
<ModelParameters {...playgroundContext} evalModelsOnly={false} />
<ModelParameters {...playgroundContext} />
</div>
<div className="min-h-[20dvh]">
<Variables />
+20 -3
View File
@@ -9,6 +9,7 @@ export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
NODE_ENV: z.enum(["development", "test", "production"]),
BUILD_ID: z.string().optional(),
NEXTAUTH_SECRET:
process.env.NODE_ENV === "production"
? z.string().min(1)
@@ -95,6 +96,17 @@ 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(),
@@ -139,9 +151,11 @@ export const env = createEnv({
// clickhouse
CLICKHOUSE_URL: z.string().url(),
CLICKHOUSE_CLUSTER_NAME: z.string().default("default"),
CLICKHOUSE_DB: z.string().default("default"),
CLICKHOUSE_USER: z.string(),
CLICKHOUSE_PASSWORD: z.string(),
CLICKHOUSE_CLUSTER_ENABLED: z.enum(["true", "false"]).default("false"),
CLICKHOUSE_CLUSTER_ENABLED: z.enum(["true", "false"]).default("true"),
// EE ui customization
LANGFUSE_UI_API_HOST: z.string().optional(),
@@ -290,6 +304,7 @@ export const env = createEnv({
NEXT_PUBLIC_DEMO_ORG_ID: process.env.NEXT_PUBLIC_DEMO_ORG_ID,
DATABASE_URL: process.env.DATABASE_URL,
NODE_ENV: process.env.NODE_ENV,
BUILD_ID: process.env.BUILD_ID,
NEXT_PUBLIC_BUILD_ID: process.env.NEXT_PUBLIC_BUILD_ID,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
NEXTAUTH_COOKIE_DOMAIN: process.env.NEXTAUTH_COOKIE_DOMAIN,
@@ -367,10 +382,10 @@ 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_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,
@@ -432,6 +447,8 @@ export const env = createEnv({
NEXT_PUBLIC_CRISP_WEBSITE_ID: process.env.NEXT_PUBLIC_CRISP_WEBSITE_ID,
// clickhouse
CLICKHOUSE_URL: process.env.CLICKHOUSE_URL,
CLICKHOUSE_CLUSTER_NAME: process.env.CLICKHOUSE_CLUSTER_NAME,
CLICKHOUSE_DB: process.env.CLICKHOUSE_DB,
CLICKHOUSE_USER: process.env.CLICKHOUSE_USER,
CLICKHOUSE_PASSWORD: process.env.CLICKHOUSE_PASSWORD,
CLICKHOUSE_CLUSTER_ENABLED: process.env.CLICKHOUSE_CLUSTER_ENABLED,
+4
View File
@@ -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,
};
}),
});
@@ -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}
/>
);
}
@@ -127,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}
/>
)}
@@ -154,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 }]}
/>
);
}
@@ -26,7 +26,7 @@ import { DatasetCompareRunPeekView } from "@/src/features/datasets/components/Da
import { useClickhouse } from "@/src/components/layouts/ClickhouseAdminToggle";
import { getQueryKey } from "@trpc/react-query";
import { useQueryClient } from "@tanstack/react-query";
import _ from "lodash";
export type RunMetrics = {
id: string;
scores: ScoreAggregate;
@@ -182,7 +182,7 @@ export function DatasetCompareRunsTable(props: {
({ datasetItemId, trace, observation, scores }) => {
if (!itemsAcc[datasetItemId]) itemsAcc[datasetItemId] = {};
itemsAcc[datasetItemId][runId] = {
_.set(itemsAcc[datasetItemId], runId, {
id: runId,
traceId: trace?.id ?? "",
observationId: observation?.id ?? undefined,
@@ -196,7 +196,7 @@ export function DatasetCompareRunsTable(props: {
: usdFormatter(trace?.totalCost)) ?? undefined,
},
scores,
};
});
},
);
@@ -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,
})),
};
}
+12 -10
View File
@@ -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 = {
@@ -175,7 +176,7 @@ export const createTempTableInClickhouse = async (
clickhouseSession: string,
) => {
const query = `
CREATE TABLE IF NOT EXISTS ${tableName} ${env.CLICKHOUSE_CLUSTER_ENABLED === "true" ? "ON CLUSTER default" : ""}
CREATE TABLE IF NOT EXISTS ${tableName} ${env.CLICKHOUSE_CLUSTER_ENABLED === "true" ? "ON CLUSTER " + env.CLICKHOUSE_CLUSTER_NAME : ""}
(
project_id String,
run_id String,
@@ -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}
`,
);
};
@@ -287,7 +289,7 @@ const getObservationLatencyAndCostForDataset = async (
const query = `
WITH agg AS (
SELECT
dateDiff('milliseconds', start_time, end_time) AS latency_ms,
dateDiff('millisecond', start_time, end_time) AS latency_ms,
total_cost AS cost,
run_id
FROM observations AS o
@@ -340,7 +342,7 @@ const getTraceLatencyAndCostForDataset = async (
SELECT
o.trace_id,
run_id,
dateDiff('milliseconds', min(start_time), max(end_time)) AS latency_ms,
dateDiff('millisecond', min(start_time), max(end_time)) AS latency_ms,
sum(total_cost) AS cost
FROM observations o JOIN ${tableName} tmp
ON tmp.project_id = o.project_id
@@ -529,10 +531,10 @@ export const getRunItemsByRunIdOrItemId = async (
) => {
const [traceScores, observationAggregates, traceAggregate] =
await Promise.all([
getScoresForTraces(
getScoresForTraces({
projectId,
runItems.map((ri) => ri.traceId),
),
traceIds: runItems.map((ri) => ri.traceId),
}),
getLatencyAndTotalCostForObservations(
projectId,
runItems
@@ -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 };
};
+14
View File
@@ -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
);
};
+86
View File
@@ -0,0 +1,86 @@
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.union([
z.record(z.union([z.string(), z.coerce.number()])).nullable(),
z.string(),
]),
tokenizerId: TokenizerSchema,
prices: PriceMapSchema,
lastUsed: z.date().nullish(),
});
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",
}
export const ModelLastUsedQueryResult = z.array(
z.object({
modelId: z.string(),
lastUsed: z.coerce.date(),
}),
);
@@ -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"
@@ -132,18 +132,18 @@ export const projectsRouter = createTRPCRouter({
action: "delete",
});
// Delete API keys from DB first
// API keys need to be deleted from cache. Otherwise, they will still be valid.
await new ApiAuthService(ctx.prisma, redis).invalidateProjectApiKeys(
input.projectId,
);
// Delete API keys from DB
await ctx.prisma.apiKey.deleteMany({
where: {
projectId: input.projectId,
},
});
// API keys need to be deleted from cache. Otherwise, they will still be valid.
await new ApiAuthService(ctx.prisma, redis).invalidateProjectApiKeys(
input.projectId,
);
await ctx.prisma.project.update({
where: {
id: input.projectId,
@@ -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 &quot;production&quot; 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}
@@ -14,11 +14,7 @@ import useProjectIdFromURL from "@/src/hooks/useProjectIdFromURL";
import { useUiCustomization } from "@/src/ee/features/ui-customization/useUiCustomization";
import { CreateLLMApiKeyForm } from "@/src/features/public-api/components/CreateLLMApiKeyForm";
export function CreateLLMApiKeyDialog({
evalModelsOnly,
}: {
evalModelsOnly?: boolean;
}) {
export function CreateLLMApiKeyDialog() {
const projectId = useProjectIdFromURL();
const [open, setOpen] = useState(false);
const hasAccess = useHasProjectAccess({
@@ -49,7 +45,6 @@ export function CreateLLMApiKeyDialog({
{open && (
<CreateLLMApiKeyForm
projectId={projectId}
evalModelsOnly={evalModelsOnly}
onSuccess={() => setOpen(false)}
customization={uiCustomization}
/>
@@ -66,12 +66,10 @@ const formSchema = z
export function CreateLLMApiKeyForm({
projectId,
evalModelsOnly,
onSuccess,
customization,
}: {
projectId?: string;
evalModelsOnly?: boolean;
onSuccess: () => void;
customization: ReturnType<typeof useUiCustomization>;
}) {
@@ -248,18 +246,11 @@ export function CreateLLMApiKeyForm({
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.values(LLMAdapter)
.filter(
(provider) =>
!evalModelsOnly ||
provider === LLMAdapter.OpenAI ||
provider === LLMAdapter.Azure,
)
.map((provider) => (
<SelectItem value={provider} key={provider}>
{provider}
</SelectItem>
))}
{Object.values(LLMAdapter).map((provider) => (
<SelectItem value={provider} key={provider}>
{provider}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
@@ -61,7 +61,7 @@ export const generateObservationsForPublicApi = async (props: QueryType) => {
WHERE o.project_id = {projectId: String}
${traceFilter ? `AND t.project_id = {projectId: String}` : ""}
AND ${appliedFilter.query}
ORDER BY start_time desc
ORDER BY event_ts desc
LIMIT 1 by id, project_id
${props.limit !== undefined && props.page !== undefined ? `LIMIT {limit: Int32} OFFSET {offset: Int32}` : ""}
`;
+1 -1
View File
@@ -58,7 +58,7 @@ export const generateTracesForPublicApi = async (
trace_id,
project_id,
sum(total_cost) as total_cost,
date_diff('milliseconds', least(min(start_time), min(end_time)), greatest(max(start_time), max(end_time))) as latency_milliseconds,
date_diff('millisecond', least(min(start_time), min(end_time)), greatest(max(start_time), max(end_time))) as latency_milliseconds,
groupArray(id) as observation_ids
FROM observations FINAL
WHERE project_id = {projectId: String}
@@ -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",

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