Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b21dd45597 | ||
|
|
c1649ce9d1 | ||
|
|
555ac90535 | ||
|
|
a84d826e22 | ||
|
|
7464059c6e | ||
|
|
f159afc49e | ||
|
|
e064f13dca | ||
|
|
8ec44d7fdb | ||
|
|
9358f21b2b | ||
|
|
4e5a3c0e07 | ||
|
|
2950a480f1 | ||
|
|
b4246ec66e | ||
|
|
dc15b72fc6 | ||
|
|
1ccfe68aae | ||
|
|
1977725efc | ||
|
|
ac444e68e9 | ||
|
|
ba58d6412c | ||
|
|
5ceadd189c | ||
|
|
4c56e69702 | ||
|
|
857cf123a4 | ||
|
|
7af78553d1 | ||
|
|
aba41aeb1c | ||
|
|
1e56085d86 | ||
|
|
f39576ccb4 | ||
|
|
408dd15d85 | ||
|
|
7a00e9415c | ||
|
|
f9dda6088f | ||
|
|
d5d570ddc8 | ||
|
|
f37dc514a5 | ||
|
|
2ec0ae8db2 | ||
|
|
3a6f441524 | ||
|
|
afec6fb433 | ||
|
|
27e61c6f51 | ||
|
|
27868582f9 | ||
|
|
e6e740839f | ||
|
|
8dc6ad601b | ||
|
|
1f38258e0c | ||
|
|
c42ff44f8f | ||
|
|
ec86cc7fe2 | ||
|
|
d213aa54a6 | ||
|
|
2a2f72cdc8 | ||
|
|
b25b841a36 | ||
|
|
0190d54815 | ||
|
|
d8d2a3c00c | ||
|
|
7f9a739ca5 | ||
|
|
65f797095e | ||
|
|
1ac5934e2a | ||
|
|
adf362c282 | ||
|
|
77e2687baa | ||
|
|
52a3276495 | ||
|
|
cd6f9606e8 | ||
|
|
e03b51657d | ||
|
|
02266d420e | ||
|
|
3b0e856410 | ||
|
|
d0a619af35 | ||
|
|
7594d3a214 | ||
|
|
ec76d94e4a | ||
|
|
f6615cc052 | ||
|
|
d0d0df3829 | ||
|
|
85d890529f | ||
|
|
91876f0082 | ||
|
|
4c65011cb6 | ||
|
|
7d0b7d4301 | ||
|
|
5d1064f5de | ||
|
|
b76d15df9d | ||
|
|
af0e57e561 | ||
|
|
ca9377ea4e | ||
|
|
9f3ac3ab9f | ||
|
|
e38deed204 | ||
|
|
19bf90dd71 | ||
|
|
0523fbfc67 | ||
|
|
0fcdac0cf1 | ||
|
|
c2bc17d4d1 | ||
|
|
7d11812fd1 | ||
|
|
877bad2234 | ||
|
|
dd90a28d1c | ||
|
|
c8c9fc0eba | ||
|
|
aafd45f771 | ||
|
|
fc6f32ebe2 | ||
|
|
765406492a | ||
|
|
3cd840766b | ||
|
|
ed4d7ceb40 | ||
|
|
3520b1d28d | ||
|
|
1f0e7b2c37 | ||
|
|
17c9fd0028 | ||
|
|
ab997018ff | ||
|
|
bc10176935 | ||
|
|
6058fc7388 | ||
|
|
923ea17fef | ||
|
|
6b3630f3c2 | ||
|
|
c4ad55b3ff | ||
|
|
45c17ca286 | ||
|
|
d1298e68a2 | ||
|
|
ab85bad70b | ||
|
|
042901ddc8 | ||
|
|
111240c2d6 | ||
|
|
6b2c14acbb | ||
|
|
37e72a4b7f | ||
|
|
ad93b6c3c5 | ||
|
|
d0e33c1c34 | ||
|
|
f02d4916a4 | ||
|
|
c6151dd042 | ||
|
|
dd34aad84d | ||
|
|
78ca9757ff | ||
|
|
805a91c9f8 | ||
|
|
24ec7884a1 | ||
|
|
18122fa334 | ||
|
|
c564608ee7 | ||
|
|
a5413c101b | ||
|
|
25f6c6966e | ||
|
|
634057e32b | ||
|
|
ff8cae0c06 | ||
|
|
8c1af8a046 | ||
|
|
d03e035d5c | ||
|
|
0a83be775f | ||
|
|
f9a5c23e9b | ||
|
|
fc0fb4a559 | ||
|
|
e0268f38bb | ||
|
|
a729306b5b | ||
|
|
85d92daafd | ||
|
|
939b233120 | ||
|
|
5612aa2d27 | ||
|
|
2a72fcc421 | ||
|
|
99a45ccd97 | ||
|
|
b123cc5a4f | ||
|
|
a3e5208a90 | ||
|
|
484ef3fcac | ||
|
|
e080690408 | ||
|
|
bd53c54435 | ||
|
|
804255b7ec | ||
|
|
0fa4b381fc | ||
|
|
01102b0128 | ||
|
|
ddd7671d5b | ||
|
|
1ff58377f1 | ||
|
|
41ab5e0b7b | ||
|
|
a78bf26bd4 | ||
|
|
a185863bc5 | ||
|
|
57b46b9f19 | ||
|
|
58b547e0f1 | ||
|
|
6554c5ee88 | ||
|
|
90fc9fb660 | ||
|
|
e2c5c9b5cc | ||
|
|
c27c32f844 | ||
|
|
d33066e012 | ||
|
|
dff8ab4faa | ||
|
|
57a42c8c4b | ||
|
|
a8258382d5 | ||
|
|
ba9d5217a4 | ||
|
|
69bf783b11 | ||
|
|
31fb4b4b20 | ||
|
|
2d2c3c5818 |
+7
-1
@@ -6,6 +6,12 @@
|
||||
DIRECT_URL="postgresql://postgres:postgres@localhost:5432/postgres"
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
|
||||
|
||||
# Clickhouse
|
||||
CLICKHOUSE_MIGRATION_URL="clickhouse://localhost:9000"
|
||||
CLICKHOUSE_URL="http://localhost:8123"
|
||||
CLICKHOUSE_USER="clickhouse"
|
||||
CLICKHOUSE_PASSWORD="clickhouse"
|
||||
|
||||
# Next Auth
|
||||
# You can generate a new secret on the command line with:
|
||||
# openssl rand -base64 32
|
||||
@@ -44,4 +50,4 @@ REDIS_AUTH="myredissecret"
|
||||
|
||||
LANGFUSE_WORKER_PASSWORD=mybasicauthsecret
|
||||
# openssl rand -hex 32 used only here
|
||||
ENCRYPTION_KEY=6c16874e5c0f0cc74ddec00425fa99fbe9ffbe412b7d5d906a4b00005df91403
|
||||
ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
|
||||
|
||||
+4
-1
@@ -18,4 +18,7 @@ REDIS_HOST="127.0.0.1"
|
||||
REDIS_PORT=6379
|
||||
REDIS_AUTH="myredissecret"
|
||||
|
||||
LANGFUSE_WORKER_PASSWORD=myworkerpassword
|
||||
LANGFUSE_WORKER_PASSWORD=myworkerpassword
|
||||
|
||||
# openssl rand -hex 32 used only here
|
||||
ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
|
||||
+63
-18
@@ -10,40 +10,43 @@ DATABASE_URL="postgresql://postgres:postgres@db:5432/postgres"
|
||||
# DIRECT_URL="postgresql://postgres:postgres@db:5432/postgres"
|
||||
# SHADOW_DATABASE_URL=
|
||||
# optional, set to true to disable automated database migrations on Docker start
|
||||
# LANGFUSE_AUTO_POSTGRES_MIGRATION_DISABLED=
|
||||
# LANGFUSE_AUTO_POSTGRES_MIGRATION_DISABLED=
|
||||
|
||||
# Next Auth
|
||||
# NEXTAUTH_URL does not need to be set when deploying on Vercel
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
|
||||
# You can generate a new secret on the command line with:
|
||||
# For each of these, you can generate a new secret on the command line with:
|
||||
# openssl rand -base64 32
|
||||
# https://next-auth.js.org/configuration/options#secret
|
||||
NEXTAUTH_SECRET="secret"
|
||||
SALT="salt"
|
||||
NEXTAUTH_SECRET="secret" # https://next-auth.js.org/configuration/options#secret
|
||||
SALT="salt" # salt used to hash api keys
|
||||
|
||||
# API level encryption for sensitive data
|
||||
# Must be 256 bits, 64 string characters in hex format, generate via: openssl rand -hex 32
|
||||
ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000"
|
||||
|
||||
# Use CSP headers to enforce HTTPS, optional
|
||||
LANGFUSE_CSP_ENFORCE_HTTPS="true"
|
||||
# LANGFUSE_CSP_ENFORCE_HTTPS="true"
|
||||
|
||||
|
||||
# Docker only, optional
|
||||
# PORT=3000
|
||||
# HOSTNAME=localhost
|
||||
|
||||
# Default project, optional
|
||||
# Default role for users who sign up, optional, can be org or org+project
|
||||
# LANGFUSE_DEFAULT_ORG_ID=
|
||||
# LANGFUSE_DEFAULT_ORG_ROLE=
|
||||
# LANGFUSE_DEFAULT_PROJECT_ID=
|
||||
# LANGFUSE_DEFAULT_PROJECT_ROLE=
|
||||
|
||||
# Enable experimental features, optional
|
||||
# LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES=true
|
||||
|
||||
# Disable automated database migrations on Docker start, optional
|
||||
# LANFUSE_WEB_MIGRATION_DISABLED=true
|
||||
|
||||
# Auth, optional configuration
|
||||
# AUTH_DOMAINS_WITH_SSO_ENFORCEMENT=domain1.com,domain2.com
|
||||
# AUTH_DISABLE_USERNAME_PASSWORD=true
|
||||
# AUTH_DISABLE_SIGNUP=true
|
||||
# AUTH_SESSION_MAX_AGE=43200 # 30 days in minutes (default)
|
||||
|
||||
# SSO, each group is optional
|
||||
# AUTH_GOOGLE_CLIENT_ID=
|
||||
@@ -94,8 +97,20 @@ LANGFUSE_CSP_ENFORCE_HTTPS="true"
|
||||
# The page size can be adjusted if needed to optimize performance
|
||||
# DB_EXPORT_PAGE_SIZE=1000
|
||||
|
||||
# Disable logging to `events` table in db
|
||||
# ENABLE_EVENT_LOG=false
|
||||
|
||||
|
||||
### START Enterprise Edition Configuration
|
||||
|
||||
# Allowlisted users that can create new organizations, by default all users can create organizations
|
||||
# LANGFUSE_ALLOWED_ORGANIZATION_CREATORS=user1@langfuse.com,user2@langfuse.com
|
||||
|
||||
# UI Customization Options
|
||||
# LANGFUSE_UI_API_HOST=https://api.example.com
|
||||
# LANGFUSE_UI_DOCUMENTATION_HREF=https://docs.example.com
|
||||
# LANGFUSE_UI_SUPPORT_HREF=https://support.example.com
|
||||
# LANGFUSE_UI_FEEDBACK_HREF=https://feedback.example.com
|
||||
|
||||
### END Enterprise Edition Configuration
|
||||
|
||||
|
||||
|
||||
@@ -104,9 +119,6 @@ LANGFUSE_CSP_ENFORCE_HTTPS="true"
|
||||
# Disable the expensive analytics queries and related features
|
||||
# LANGFUSE_DISABLE_EXPENSIVE_POSTGRES_QUERIES="true"
|
||||
|
||||
# Customize the default datetime offset filter used in for the UI tables
|
||||
# LANGFUSE_DEFAULT_TABLE_DATETIME_OFFSET="7" # int (days)
|
||||
|
||||
### END Envs to be deprecated in Langfuse v3.0
|
||||
|
||||
|
||||
@@ -133,6 +145,7 @@ LANGFUSE_CSP_ENFORCE_HTTPS="true"
|
||||
# SENTRY_AUTH_TOKEN=
|
||||
# Used to determine the Sentry sample rate
|
||||
# LANGFUSE_TRACING_SAMPLE_RATE=
|
||||
# LANGFUSE_WORKER_BETTERSTACK_TOKEN=
|
||||
|
||||
|
||||
# Cloudflare Turnstile
|
||||
@@ -140,16 +153,48 @@ LANGFUSE_CSP_ENFORCE_HTTPS="true"
|
||||
# TURNSTILE_SECRET_KEY=
|
||||
|
||||
# Demo project that users can use to try the platform
|
||||
# NEXT_PUBLIC_DEMO_ORG_ID=
|
||||
# NEXT_PUBLIC_DEMO_PROJECT_ID=
|
||||
|
||||
# Crisp chat
|
||||
# NEXT_PUBLIC_CRISP_WEBSITE_ID=
|
||||
|
||||
# Encryption
|
||||
# ENCRYPTION_KEY=
|
||||
|
||||
# Admin API
|
||||
# ADMIN_API_KEY=
|
||||
|
||||
# Redis
|
||||
# REDIS_HOST=
|
||||
# REDIS_PORT=
|
||||
# REDIS_AUTH=
|
||||
# REDIS_CONNECTION_STRING=
|
||||
|
||||
# Cache configuration
|
||||
# LANGFUSE_CACHE_API_KEY_ENABLED=
|
||||
# LANGFUSE_CACHE_API_KEY_TTL_SECONDS=
|
||||
# LANGFUSE_CACHE_PROMPT_ENABLED=
|
||||
# LANGFUSE_CACHE_PROMPT_TTL_SECONDS=
|
||||
|
||||
### END Langfuse Cloud Config
|
||||
|
||||
|
||||
## START Langfuse V3 Ingestion
|
||||
|
||||
# Clickhouse
|
||||
# CLICKHOUSE_MIGRATION_URL=
|
||||
# CLICKHOUSE_URL=
|
||||
# CLICKHOUSE_USER=
|
||||
# CLICKHOUSE_PASSWORD=
|
||||
|
||||
# Ingestion
|
||||
# LANGFUSE_INGESTION_BUFFER_TTL_SECONDS=
|
||||
# LANGFUSE_INGESTION_FLUSH_DELAY_MS=
|
||||
# LANGFUSE_INGESTION_FLUSH_ATTEMPTS=
|
||||
# LANGFUSE_INGESTION_FLUSH_PROCESSING_CONCURRENCY=
|
||||
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_BATCH_SIZE=
|
||||
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS=
|
||||
# LANGFUSE_INGESTION_CLICKHOUSE_MAX_ATTEMPTS=
|
||||
# LANGFUSE_LOG_LEVEL=
|
||||
# LANGFUSE_LEGACY_INGESTION_WORKER_CONCURRENCY=
|
||||
# LANGFUSE_ASYNC_INGESTION_PROCESSING="true"
|
||||
|
||||
## END Langfuse V3 Ingestion
|
||||
@@ -63,6 +63,7 @@ jobs:
|
||||
fi
|
||||
|
||||
tests-web:
|
||||
timeout-minutes: 20
|
||||
runs-on: ubuntu-latest
|
||||
name: tests-web (node${{ matrix.node-version }}, pg${{ matrix.postgres-version }})
|
||||
strategy:
|
||||
@@ -93,7 +94,7 @@ jobs:
|
||||
- name: Load default env
|
||||
run: |
|
||||
cp .env.dev.example .env
|
||||
cp .env.dev.example web/.env
|
||||
grep -v -e '^REDIS_HOST=' .env.dev.example > .env
|
||||
|
||||
- name: Run + migrate
|
||||
run: |
|
||||
@@ -118,6 +119,7 @@ jobs:
|
||||
run: pnpm --filter=web run test
|
||||
|
||||
tests-worker:
|
||||
timeout-minutes: 20
|
||||
runs-on: ubuntu-latest
|
||||
name: tests-worker (node${{ matrix.node-version }}, pg${{ matrix.postgres-version }})
|
||||
strategy:
|
||||
@@ -145,10 +147,17 @@ jobs:
|
||||
run: |
|
||||
pnpm install
|
||||
|
||||
- name: Install golang-migrate for Clickhouse migrations
|
||||
run: |
|
||||
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.16.2/migrate.linux-amd64.tar.gz | tar xvz
|
||||
sudo mv migrate /usr/bin/migrate
|
||||
which migrate
|
||||
|
||||
- name: Load default env
|
||||
run: |
|
||||
cp .env.dev.example .env
|
||||
cp .env.dev.example web/.env
|
||||
cp .env.dev.example worker/.env
|
||||
|
||||
- name: Run + migrate
|
||||
run: |
|
||||
@@ -158,6 +167,7 @@ jobs:
|
||||
|
||||
- name: Seed DB
|
||||
run: |
|
||||
pnpm run --filter=shared ch:up
|
||||
pnpm run db:migrate
|
||||
pnpm run db:seed
|
||||
|
||||
@@ -209,10 +219,62 @@ jobs:
|
||||
- name: Run e2e tests
|
||||
run: pnpm --filter=web run test:e2e
|
||||
|
||||
e2e-server-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9.5.0
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "pnpm-lock.yaml"
|
||||
|
||||
- name: install dependencies
|
||||
run: |
|
||||
pnpm install
|
||||
|
||||
- name: Load default env
|
||||
run: |
|
||||
cp .env.dev.example .env
|
||||
echo "LANGFUSE_ASYNC_INGESTION_PROCESSING=true" >> .env
|
||||
echo "LANGFUSE_CACHE_API_KEY_ENABLED=true" >> .env
|
||||
echo "LANGFUSE_CACHE_PROMPT_ENABLED=true" >> .env
|
||||
|
||||
- name: Run + migrate
|
||||
run: |
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
docker compose ps
|
||||
sleep 5 # Wait for PostgreSQL to accept connections
|
||||
|
||||
- name: Seed DB
|
||||
run: |
|
||||
pnpm run db:migrate
|
||||
pnpm run db:seed:examples
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
|
||||
- name: Run server
|
||||
run: (pnpm run start&)
|
||||
|
||||
- name: Run e2e tests
|
||||
run: pnpm --filter=web run test:e2e:server
|
||||
|
||||
all-ci-passed:
|
||||
# This allows us to have a branch protection rule for tests and deploys with matrix
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, tests-web, tests-worker, e2e-tests, test-docker-build]
|
||||
needs:
|
||||
[
|
||||
lint,
|
||||
tests-web,
|
||||
tests-worker,
|
||||
e2e-tests,
|
||||
test-docker-build,
|
||||
e2e-server-tests,
|
||||
]
|
||||
if: always()
|
||||
steps:
|
||||
- name: Successful deploy
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
"on":
|
||||
push:
|
||||
branches:
|
||||
- production
|
||||
- production
|
||||
name: Deploy to web
|
||||
jobs:
|
||||
porter-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
- name: Set Github tag
|
||||
id: vars
|
||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
- name: Setup porter
|
||||
uses: porter-dev/setup-porter@v0.1.0
|
||||
- name: Deploy stack
|
||||
timeout-minutes: 30
|
||||
run: exec porter apply
|
||||
env:
|
||||
PORTER_CLUSTER: "4060"
|
||||
PORTER_DEPLOYMENT_TARGET_ID: 3be075c0-b121-4123-aec1-5dc571626b0c
|
||||
PORTER_HOST: https://dashboard.porter.run
|
||||
PORTER_PR_NUMBER: ${{ github.event.number }}
|
||||
PORTER_PROJECT: "12565"
|
||||
PORTER_REPO_NAME: ${{ github.event.repository.name }}
|
||||
PORTER_STACK_NAME: web
|
||||
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
|
||||
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4060 }}
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
- name: Set Github tag
|
||||
id: vars
|
||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
- name: Setup porter
|
||||
uses: porter-dev/setup-porter@v0.1.0
|
||||
- name: Deploy stack
|
||||
timeout-minutes: 30
|
||||
run: exec porter apply
|
||||
env:
|
||||
PORTER_CLUSTER: "4060"
|
||||
PORTER_DEPLOYMENT_TARGET_ID: 3be075c0-b121-4123-aec1-5dc571626b0c
|
||||
PORTER_HOST: https://dashboard.porter.run
|
||||
PORTER_PR_NUMBER: ${{ github.event.number }}
|
||||
PORTER_PROJECT: "12565"
|
||||
PORTER_REPO_NAME: ${{ github.event.repository.name }}
|
||||
PORTER_STACK_NAME: web
|
||||
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
|
||||
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4060 }}
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
"on":
|
||||
push:
|
||||
branches:
|
||||
- production
|
||||
- production
|
||||
name: Deploy to worker (EU temp)
|
||||
jobs:
|
||||
porter-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
- name: Set Github tag
|
||||
id: vars
|
||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
- name: Setup porter
|
||||
uses: porter-dev/setup-porter@v0.1.0
|
||||
- name: Deploy stack
|
||||
timeout-minutes: 30
|
||||
run: exec porter apply
|
||||
env:
|
||||
PORTER_CLUSTER: "4060"
|
||||
PORTER_DEPLOYMENT_TARGET_ID: 3be075c0-b121-4123-aec1-5dc571626b0c
|
||||
PORTER_HOST: https://dashboard.porter.run
|
||||
PORTER_PR_NUMBER: ${{ github.event.number }}
|
||||
PORTER_PROJECT: "12565"
|
||||
PORTER_REPO_NAME: ${{ github.event.repository.name }}
|
||||
PORTER_STACK_NAME: worker
|
||||
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
|
||||
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4060 }}
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
- name: Set Github tag
|
||||
id: vars
|
||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
- name: Setup porter
|
||||
uses: porter-dev/setup-porter@v0.1.0
|
||||
- name: Deploy stack
|
||||
timeout-minutes: 30
|
||||
run: exec porter apply
|
||||
env:
|
||||
PORTER_CLUSTER: "4060"
|
||||
PORTER_DEPLOYMENT_TARGET_ID: 3be075c0-b121-4123-aec1-5dc571626b0c
|
||||
PORTER_HOST: https://dashboard.porter.run
|
||||
PORTER_PR_NUMBER: ${{ github.event.number }}
|
||||
PORTER_PROJECT: "12565"
|
||||
PORTER_REPO_NAME: ${{ github.event.repository.name }}
|
||||
PORTER_STACK_NAME: worker
|
||||
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
|
||||
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4060 }}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
"on":
|
||||
push:
|
||||
branches:
|
||||
- production
|
||||
name: Deploy to worker (US)
|
||||
jobs:
|
||||
porter-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
- name: Set Github tag
|
||||
id: vars
|
||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
- name: Setup porter
|
||||
uses: porter-dev/setup-porter@v0.1.0
|
||||
- name: Deploy stack
|
||||
timeout-minutes: 30
|
||||
run: exec porter apply
|
||||
env:
|
||||
PORTER_CLUSTER: "4038"
|
||||
PORTER_DEPLOYMENT_TARGET_ID: 260cd04e-5dc8-4b22-a291-ad4453f981f7
|
||||
PORTER_HOST: https://dashboard.getporter.dev
|
||||
PORTER_PR_NUMBER: ${{ github.event.number }}
|
||||
PORTER_PROJECT: "12565"
|
||||
PORTER_REPO_NAME: ${{ github.event.repository.name }}
|
||||
PORTER_STACK_NAME: worker
|
||||
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
|
||||
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4038 }}
|
||||
@@ -1,5 +1,12 @@
|
||||
name: Snyk Container
|
||||
on: push
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- "dependabot/**"
|
||||
pull_request:
|
||||
branches:
|
||||
- "**"
|
||||
|
||||
jobs:
|
||||
snyk:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -18,7 +25,7 @@ jobs:
|
||||
args: --file=web/Dockerfile
|
||||
|
||||
- name: Upload result to GitHub Code Scanning
|
||||
uses: github/codeql-action/upload-sarif@v2
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: snyk.sarif
|
||||
category: web
|
||||
@@ -33,7 +40,7 @@ jobs:
|
||||
args: --file=worker/Dockerfile
|
||||
|
||||
- name: Upload result to GitHub Code Scanning
|
||||
uses: github/codeql-action/upload-sarif@v2
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: snyk.sarif
|
||||
category: worker
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||

|
||||

|
||||
|
||||
<div align="center"><h1> Langfuse: Open Source LLM Engineering Platform</h4></div>
|
||||
<div align="center"><h2> Debug and improve your LLM app</h4></div>
|
||||
<div align="center"><h3> LLM Observability, Prompt Management, LLM Evaluations, Datasets, LLM Metrics and Prompt Playground</h4></div>
|
||||
|
||||
<div align="center">
|
||||
<div>
|
||||
@@ -37,7 +41,7 @@
|
||||
</div>
|
||||
</br>
|
||||
|
||||
## Overview
|
||||
## Langfuse Overview
|
||||
|
||||
_Unmute video for voice-over_
|
||||
|
||||
@@ -45,18 +49,18 @@ https://github.com/langfuse/langfuse/assets/2834609/a94062e9-c782-4ee9-af59-dee6
|
||||
|
||||
### Develop
|
||||
|
||||
- **Observability:** Instrument your app and start ingesting traces to Langfuse ([Quickstart](https://langfuse.com/docs/get-started), [Integrations](https://langfuse.com/docs/integrations) [Tracing](https://langfuse.com/docs/tracing))
|
||||
- **LLM Observability:** Instrument your app and start ingesting traces to Langfuse ([Quickstart](https://langfuse.com/docs/get-started), [Integrations](https://langfuse.com/docs/integrations) [Tracing](https://langfuse.com/docs/tracing))
|
||||
- **Langfuse UI:** Inspect and debug complex logs ([Demo](https://langfuse.com/docs/demo), [Tracing](https://langfuse.com/docs/tracing))
|
||||
- **Prompt Management:** Manage, version and deploy prompts from within Langfuse ([Prompt Management](https://langfuse.com/docs/prompts/get-started))
|
||||
- **Prompt Engineering:** Test and iterate on your prompts with the [LLM Playground](https://langfuse.com/docs/playground)
|
||||
|
||||
### Monitor
|
||||
|
||||
- **Analytics:** Track metrics (cost, latency, quality) and gain insights from dashboards & data exports ([Analytics](https://langfuse.com/docs/analytics))
|
||||
- **Evals:** Collect and calculate scores for your LLM completions ([Scores & Evaluations](https://langfuse.com/docs/scores))
|
||||
- Run model-based evaluations ([Model-based evaluations](https://langfuse.com/docs/scores/model-based-evals)) within Langfuse
|
||||
- **LLM Analytics:** Track metrics (cost, latency, quality) and gain insights from dashboards & data exports ([Analytics](https://langfuse.com/docs/analytics))
|
||||
- **LLM Evaluations:** Collect and calculate scores for your LLM completions ([Scores & Evaluations](https://langfuse.com/docs/scores))
|
||||
- Run ([Model-based evaluations](https://langfuse.com/docs/scores/model-based-evals)) and LLM-as-a-Judge within Langfuse
|
||||
- Collect user feedback ([User Feedback](https://langfuse.com/docs/scores/user-feedback))
|
||||
- Manually score observations in Langfuse ([Manual Scores](https://langfuse.com/docs/scores/manually))
|
||||
- Manually score LLM outputs in Langfuse ([Manual Scores](https://langfuse.com/docs/scores/manually))
|
||||
|
||||
### Test
|
||||
|
||||
@@ -72,6 +76,8 @@ Managed deployment by the Langfuse team, generous free-tier (hobby plan), no cre
|
||||
|
||||
**[» Langfuse Cloud](https://cloud.langfuse.com)**
|
||||
|
||||
## Self-Hosting Open Source LLM Observability with Langfuse
|
||||
|
||||
### Localhost (docker)
|
||||
|
||||
```bash
|
||||
@@ -98,7 +104,7 @@ Templated deployments: [Railway, GCP Cloud Run, AWS Fargate, Kubernetes and othe
|
||||
|
||||
You need a Langfuse public and secret key to get started. Sign up [here](https://cloud.langfuse.com) and find them in your project settings.
|
||||
|
||||
### Ingesting Data · Instrumenting Your Application
|
||||
### Ingesting Data · Instrumenting Your Application · LLM Observability with Langfuse
|
||||
|
||||
Note: We recommend using our fully async, typed [SDKs](https://langfuse.com/docs/sdk) that allow you to instrument any LLM application with any underlying model. They are available in [Python (Decorators)](https://langfuse.com/docs/sdk/python) & [JS/TS](https://langfuse.com/docs/sdk/typescript). The SDKs will always be the most fully featured and stable way to ingest data into Langfuse.
|
||||
|
||||
@@ -106,7 +112,7 @@ You may want to use another integration to get started quickly or implement a us
|
||||
|
||||
See the [→ Quickstart](https://langfuse.com/docs/get-started) to integrate Langfuse.
|
||||
|
||||
### Integrations
|
||||
### LLM Observability Integrations
|
||||
|
||||
| Integration | Supports | Description |
|
||||
| -------------------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
@@ -127,15 +133,14 @@ Packages that integrate with Langfuse:
|
||||
| [AI SDK by Vercel](/docs/sdk/typescript/example-vercel-ai) | Typescript SDK that makes streaming LLM outputs super easy. |
|
||||
| [Flowise](/docs/integrations/flowise) | JS/TS no-code builder for customized LLM flows. |
|
||||
| [Langflow](/docs/integrations/langflow) | Python-based UI for LangChain, designed with react-flow to provide an effortless way to experiment and prototype flows. |
|
||||
| [Superagent](/docs/integrations/superagent) | Open Source AI Assistant Framework & API for prototyping and deployment of agents. |
|
||||
|
||||
## Questions and feedback
|
||||
|
||||
### Ideas and roadmap
|
||||
|
||||
- [GitHub Discussions](https://github.com/orgs/langfuse/discussions)
|
||||
- [Feature Requests](https://langfuse.com/idea)
|
||||
- [Roadmap](https://langfuse.com/roadmap)
|
||||
- [GitHub Discussions](https://github.com/orgs/langfuse/discussions)
|
||||
- [Feature Requests](https://langfuse.com/idea
|
||||
|
||||
### Support and feedback
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ services:
|
||||
- DATABASE_URL=postgresql://postgres:postgres@db:5432/postgres
|
||||
- NEXTAUTH_SECRET=mysecret
|
||||
- SALT=mysalt
|
||||
- ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 # generate via `openssl rand -hex 32`
|
||||
- NEXTAUTH_URL=http://localhost:3000
|
||||
- TELEMETRY_ENABLED=${TELEMETRY_ENABLED:-true}
|
||||
- LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES=${LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES:-false}
|
||||
|
||||
+25
-3
@@ -1,4 +1,22 @@
|
||||
services:
|
||||
clickhouse:
|
||||
image: clickhouse/clickhouse-server
|
||||
user: "101:101"
|
||||
container_name: clickhouse
|
||||
hostname: clickhouse
|
||||
environment:
|
||||
CLICKHOUSE_DB: default
|
||||
CLICKHOUSE_USER: clickhouse
|
||||
CLICKHOUSE_PASSWORD: clickhouse
|
||||
volumes:
|
||||
- langfuse_clickhouse_data:/var/lib/clickhouse
|
||||
- langfuse_clickhouse_logs:/var/log/clickhouse-server
|
||||
ports:
|
||||
- "8123:8123"
|
||||
- "9000:9000"
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
redis:
|
||||
image: redis:7.2.4
|
||||
restart: always
|
||||
@@ -7,7 +25,7 @@ services:
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
db:
|
||||
postgres:
|
||||
image: postgres:${POSTGRES_VERSION:-latest}
|
||||
restart: always
|
||||
command: ["postgres", "-c", "log_statement=all"]
|
||||
@@ -18,8 +36,12 @@ services:
|
||||
ports:
|
||||
- 5432:5432
|
||||
volumes:
|
||||
- database_data:/var/lib/postgresql/data
|
||||
- langfuse_postgres_data:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
database_data:
|
||||
langfuse_postgres_data:
|
||||
driver: local
|
||||
langfuse_clickhouse_data:
|
||||
driver: local
|
||||
langfuse_clickhouse_logs:
|
||||
driver: local
|
||||
|
||||
@@ -10,6 +10,7 @@ services:
|
||||
- DATABASE_URL=postgresql://postgres:postgres@db:5432/postgres
|
||||
- NEXTAUTH_SECRET=mysecret
|
||||
- SALT=mysalt
|
||||
- ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 # generate via `openssl rand -hex 32`
|
||||
- NEXTAUTH_URL=http://localhost:3000
|
||||
- TELEMETRY_ENABLED=${TELEMETRY_ENABLED:-true}
|
||||
- LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES=${LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES:-false}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { env } from "../env";
|
||||
|
||||
export const isEeAvailable: boolean =
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined;
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined ||
|
||||
env.LANGFUSE_EE_LICENSE_KEY !== undefined;
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { env as sharedEnv } from "@langfuse/shared";
|
||||
|
||||
const EnvSchema = z.object({
|
||||
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION: z.string().optional(),
|
||||
ADMIN_API_KEY: z.string().optional(),
|
||||
LANGFUSE_EE_LICENSE_KEY: z.string().optional(),
|
||||
});
|
||||
|
||||
export const env = { ...sharedEnv, ...EnvSchema.parse(process.env) };
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { isEeAvailable } from "./ee-license-check";
|
||||
|
||||
@@ -5,16 +5,36 @@ types:
|
||||
id:
|
||||
type: string
|
||||
docs: The unique identifier of a trace
|
||||
timestamp: datetime
|
||||
name: optional<string>
|
||||
input: optional<unknown>
|
||||
output: optional<unknown>
|
||||
sessionId: optional<string>
|
||||
release: optional<string>
|
||||
version: optional<string>
|
||||
userId: optional<string>
|
||||
metadata: optional<unknown>
|
||||
tags: optional<list<string>>
|
||||
timestamp:
|
||||
type: datetime
|
||||
docs: The timestamp when the trace was created
|
||||
name:
|
||||
type: optional<string>
|
||||
docs: The name of the trace
|
||||
input:
|
||||
type: optional<unknown>
|
||||
docs: The input data of the trace. Can be any JSON.
|
||||
output:
|
||||
type: optional<unknown>
|
||||
docs: The output data of the trace. Can be any JSON.
|
||||
sessionId:
|
||||
type: optional<string>
|
||||
docs: The session identifier associated with the trace
|
||||
release:
|
||||
type: optional<string>
|
||||
docs: The release version of the application when the trace was created
|
||||
version:
|
||||
type: optional<string>
|
||||
docs: The version of the trace
|
||||
userId:
|
||||
type: optional<string>
|
||||
docs: The user identifier associated with the trace
|
||||
metadata:
|
||||
type: optional<unknown>
|
||||
docs: The metadata associated with the trace. Can be any JSON.
|
||||
tags:
|
||||
type: optional<list<string>>
|
||||
docs: The tags associated with the trace. Can be an array of strings or null.
|
||||
public:
|
||||
type: optional<boolean>
|
||||
docs: Public traces are accessible via url without login
|
||||
@@ -65,37 +85,97 @@ types:
|
||||
traces: list<Trace>
|
||||
Observation:
|
||||
properties:
|
||||
id: string
|
||||
traceId: optional<string>
|
||||
type: string
|
||||
name: optional<string>
|
||||
startTime: datetime
|
||||
endTime: optional<datetime>
|
||||
completionStartTime: optional<datetime>
|
||||
model: optional<string>
|
||||
modelParameters: optional<map<string, MapValue>>
|
||||
input: optional<unknown>
|
||||
version: optional<string>
|
||||
metadata: optional<unknown>
|
||||
output: optional<unknown>
|
||||
usage: optional<Usage>
|
||||
level: ObservationLevel
|
||||
statusMessage: optional<string>
|
||||
parentObservationId: optional<string>
|
||||
promptId: optional<string>
|
||||
id:
|
||||
type: string
|
||||
docs: The unique identifier of the observation
|
||||
traceId:
|
||||
type: optional<string>
|
||||
docs: The trace ID associated with the observation
|
||||
type:
|
||||
type: string
|
||||
docs: The type of the observation
|
||||
name:
|
||||
type: optional<string>
|
||||
docs: The name of the observation
|
||||
startTime:
|
||||
type: datetime
|
||||
docs: The start time of the observation
|
||||
endTime:
|
||||
type: optional<datetime>
|
||||
docs: The end time of the observation.
|
||||
completionStartTime:
|
||||
type: optional<datetime>
|
||||
docs: The completion start time of the observation
|
||||
model:
|
||||
type: optional<string>
|
||||
docs: The model used for the observation
|
||||
modelParameters:
|
||||
type: optional<map<string, MapValue>>
|
||||
docs: The parameters of the model used for the observation
|
||||
input:
|
||||
type: optional<unknown>
|
||||
docs: The input data of the observation
|
||||
version:
|
||||
type: optional<string>
|
||||
docs: The version of the observation
|
||||
metadata:
|
||||
type: optional<unknown>
|
||||
docs: Additional metadata of the observation
|
||||
output:
|
||||
type: optional<unknown>
|
||||
docs: The output data of the observation
|
||||
usage:
|
||||
type: optional<Usage>
|
||||
docs: The usage data of the observation
|
||||
level:
|
||||
type: ObservationLevel
|
||||
docs: The level of the observation
|
||||
statusMessage:
|
||||
type: optional<string>
|
||||
docs: The status message of the observation
|
||||
parentObservationId:
|
||||
type: optional<string>
|
||||
docs: The parent observation ID
|
||||
promptId:
|
||||
type: optional<string>
|
||||
docs: The prompt ID associated with the observation
|
||||
|
||||
ObservationsView:
|
||||
extends: Observation
|
||||
properties:
|
||||
modelId: optional<string>
|
||||
inputPrice: optional<double>
|
||||
outputPrice: optional<double>
|
||||
totalPrice: optional<double>
|
||||
calculatedInputCost: optional<double>
|
||||
calculatedOutputCost: optional<double>
|
||||
calculatedTotalCost: optional<double>
|
||||
latency: optional<double>
|
||||
timeToFirstToken: optional<double>
|
||||
promptName:
|
||||
type: optional<string>
|
||||
docs: The name of the prompt associated with the observation
|
||||
promptVersion:
|
||||
type: optional<integer>
|
||||
docs: The version of the prompt associated with the observation
|
||||
modelId:
|
||||
type: optional<string>
|
||||
docs: The unique identifier of the model
|
||||
inputPrice:
|
||||
type: optional<double>
|
||||
docs: The price of the input in USD
|
||||
outputPrice:
|
||||
type: optional<double>
|
||||
docs: The price of the output in USD.
|
||||
totalPrice:
|
||||
type: optional<double>
|
||||
docs: The total price in USD.
|
||||
calculatedInputCost:
|
||||
type: optional<double>
|
||||
docs: The calculated cost of the input in USD
|
||||
calculatedOutputCost:
|
||||
type: optional<double>
|
||||
docs: The calculated cost of the output in USD
|
||||
calculatedTotalCost:
|
||||
type: optional<double>
|
||||
docs: The calculated total cost in USD
|
||||
latency:
|
||||
type: optional<double>
|
||||
docs: The latency in seconds.
|
||||
timeToFirstToken:
|
||||
type: optional<double>
|
||||
docs: The time to the first token in seconds
|
||||
|
||||
Usage:
|
||||
docs: Standard interface for usage and cost
|
||||
@@ -237,14 +317,30 @@ types:
|
||||
updatedAt: datetime
|
||||
DatasetRun:
|
||||
properties:
|
||||
id: string
|
||||
name: string
|
||||
description: optional<string>
|
||||
metadata: optional<unknown>
|
||||
datasetId: string
|
||||
datasetName: string
|
||||
createdAt: datetime
|
||||
updatedAt: datetime
|
||||
id:
|
||||
type: string
|
||||
docs: Unique identifier of the dataset run
|
||||
name:
|
||||
type: string
|
||||
docs: Name of the dataset run
|
||||
description:
|
||||
type: optional<string>
|
||||
docs: Description of the run
|
||||
metadata:
|
||||
type: optional<unknown>
|
||||
docs: Metadata of the dataset run
|
||||
datasetId:
|
||||
type: string
|
||||
docs: Id of the associated dataset
|
||||
datasetName:
|
||||
type: string
|
||||
docs: Name of the associated dataset
|
||||
createdAt:
|
||||
type: datetime
|
||||
docs: The date and time when the dataset run was created
|
||||
updatedAt:
|
||||
type: datetime
|
||||
docs: The date and time when the dataset run was last updated
|
||||
DatasetRunWithItems:
|
||||
extends: DatasetRun
|
||||
properties:
|
||||
|
||||
@@ -49,7 +49,7 @@ types:
|
||||
sourceObservationId: optional<string>
|
||||
id:
|
||||
type: optional<string>
|
||||
docs: Dataset items are upserted on their id. Id needs to be globally unique and cannot be reused across datasets.
|
||||
docs: Dataset items are upserted on their id. Id needs to be unique (project-level) and cannot be reused across datasets.
|
||||
status:
|
||||
type: optional<commons.DatasetStatus>
|
||||
docs: Defaults to ACTIVE for newly created items
|
||||
|
||||
@@ -39,6 +39,9 @@ service:
|
||||
toStartTime:
|
||||
type: optional<datetime>
|
||||
docs: Retrieve only observations with a start_time before this datetime (ISO 8601).
|
||||
version:
|
||||
type: optional<string>
|
||||
docs: Optional filter to only include observations with a certain version.
|
||||
response: ObservationsViews
|
||||
|
||||
types:
|
||||
|
||||
@@ -41,11 +41,17 @@ service:
|
||||
limit:
|
||||
type: optional<integer>
|
||||
docs: limit of items per page
|
||||
fromUpdatedAt:
|
||||
type: optional<datetime>
|
||||
docs: Optional filter to only include prompt versions created/updated on or after a certain datetime (ISO 8601)
|
||||
toUpdatedAt:
|
||||
type: optional<datetime>
|
||||
docs: Optional filter to only include prompt versions created/updated before a certain datetime (ISO 8601)
|
||||
|
||||
response: PromptMetaListResponse
|
||||
|
||||
create:
|
||||
docs: Create a prompt
|
||||
docs: Create a new version for the prompt with the given `name`
|
||||
method: POST
|
||||
path: /prompts
|
||||
request: CreatePromptRequest
|
||||
@@ -63,6 +69,10 @@ types:
|
||||
versions: list<integer>
|
||||
labels: list<string>
|
||||
tags: list<string>
|
||||
lastUpdatedAt: datetime
|
||||
lastConfig:
|
||||
type: unknown
|
||||
docs: Config object of the most recent prompt version that matches the filters (if any are provided)
|
||||
|
||||
CreatePromptRequest:
|
||||
union:
|
||||
|
||||
@@ -7,7 +7,7 @@ service:
|
||||
base-path: /api/public
|
||||
endpoints:
|
||||
list:
|
||||
docs: Get sessions.
|
||||
docs: Get sessions
|
||||
method: GET
|
||||
path: /sessions
|
||||
request:
|
||||
|
||||
@@ -16,7 +16,7 @@ service:
|
||||
docs: The unique langfuse identifier of a trace
|
||||
response: commons.TraceWithFullDetails
|
||||
list:
|
||||
docs: Get list of traces.
|
||||
docs: Get list of traces
|
||||
method: GET
|
||||
path: /traces
|
||||
request:
|
||||
@@ -44,6 +44,12 @@ service:
|
||||
type: optional<string>
|
||||
allow-multiple: true
|
||||
docs: Only traces that include all of these tags will be returned.
|
||||
version:
|
||||
type: optional<string>
|
||||
docs: Optional filter to only include traces with a certain version.
|
||||
release:
|
||||
type: optional<string>
|
||||
docs: Optional filter to only include traces with a certain release.
|
||||
response: Traces
|
||||
|
||||
types:
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse",
|
||||
"version": "2.65.0",
|
||||
"version": "2.71.0",
|
||||
"author": "engineering@langfuse.com",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
@@ -16,8 +16,9 @@
|
||||
"db:seed": "turbo run db:seed",
|
||||
"db:seed:examples": "turbo run db:seed:examples",
|
||||
"nuke": "bash ./scripts/nuke.sh",
|
||||
"dx": "pnpm i && pnpm run infra:dev:up && pnpm --filter=shared run db:reset && pnpm --filter=shared run db:seed:examples && pnpm run dev",
|
||||
"dx-f": "pnpm i && pnpm run infra:dev:up && pnpm --filter=shared run db:reset -f && pnpm --filter=shared run db:seed:examples && pnpm run dev",
|
||||
"dx": "pnpm i && pnpm run infra:dev:up && pnpm --filter=shared run db:reset && pnpm --filter=shared run ch:reset && pnpm --filter=shared run db:seed:examples && pnpm run dev",
|
||||
"dx-f": "pnpm i && pnpm run infra:dev:up && pnpm --filter=shared run db:reset -f && pnpm --filter=shared run ch:reset && pnpm --filter=shared run db:seed:examples && pnpm run dev",
|
||||
"dx:skip-infra": "pnpm i && pnpm --filter=shared run db:reset && pnpm --filter=shared run ch:reset && pnpm --filter=shared run db:seed:examples && pnpm run dev",
|
||||
"build": "turbo run build",
|
||||
"start": "turbo run start",
|
||||
"dev": "turbo run dev",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE traces;
|
||||
@@ -0,0 +1,26 @@
|
||||
CREATE TABLE traces (
|
||||
`id` String,
|
||||
`timestamp` DateTime64(3),
|
||||
`name` String,
|
||||
`user_id` Nullable(String),
|
||||
`metadata` Map(String, String) CODEC(ZSTD(1)),
|
||||
`release` Nullable(String),
|
||||
`version` Nullable(String),
|
||||
`project_id` String,
|
||||
`public` Bool,
|
||||
`bookmarked` Bool,
|
||||
`tags` Array(String),
|
||||
`input` Nullable(String) CODEC(ZSTD(1)),
|
||||
`output` Nullable(String) CODEC(ZSTD(1)),
|
||||
`session_id` Nullable(String),
|
||||
`created_at` DateTime64(3) DEFAULT now(),
|
||||
`updated_at` DateTime64(3) DEFAULT now(),
|
||||
INDEX idx_id id TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_res_metadata_key mapKeys(metadata) TYPE bloom_filter(0.01) GRANULARITY 1,
|
||||
INDEX idx_res_metadata_value mapValues(metadata) TYPE bloom_filter(0.01) GRANULARITY 1
|
||||
) ENGINE = ReplacingMergeTree Partition by toYYYYMM(timestamp)
|
||||
ORDER BY (
|
||||
project_id,
|
||||
toUnixTimestamp(timestamp),
|
||||
id
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE observations;
|
||||
@@ -0,0 +1,50 @@
|
||||
CREATE TABLE observations (
|
||||
`id` String,
|
||||
`trace_id` String,
|
||||
`project_id` String,
|
||||
`type` LowCardinality(String),
|
||||
`parent_observation_id` Nullable(String),
|
||||
`start_time` DateTime64(3),
|
||||
`end_time` Nullable(DateTime64(3)),
|
||||
`name` String,
|
||||
`metadata` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
|
||||
`level` LowCardinality(String),
|
||||
`status_message` Nullable(String),
|
||||
`version` Nullable(String),
|
||||
`input` Nullable(String) CODEC(ZSTD(3)),
|
||||
`output` Nullable(String) CODEC(ZSTD(3)),
|
||||
`provided_model_name` Nullable(String),
|
||||
`internal_model_id` Nullable(String),
|
||||
`model_parameters` Nullable(String),
|
||||
`provided_input_usage_units` Nullable(Decimal64(12)),
|
||||
`provided_output_usage_units` Nullable(Decimal64(12)),
|
||||
`provided_total_usage_units` Nullable(Decimal64(12)),
|
||||
`input_usage_units` Nullable(Decimal64(12)),
|
||||
`output_usage_units` Nullable(Decimal64(12)),
|
||||
`total_usage_units` Nullable(Decimal64(12)),
|
||||
`unit` Nullable(String),
|
||||
`provided_input_cost` Nullable(Decimal64(12)),
|
||||
`provided_output_cost` Nullable(Decimal64(12)),
|
||||
`provided_total_cost` Nullable(Decimal64(12)),
|
||||
`input_cost` Nullable(Decimal64(12)),
|
||||
`output_cost` Nullable(Decimal64(12)),
|
||||
`total_cost` Nullable(Decimal64(12)),
|
||||
`completion_start_time` Nullable(DateTime64(3)),
|
||||
`prompt_id` Nullable(String),
|
||||
`prompt_name` Nullable(String),
|
||||
`prompt_version` Nullable(UInt16),
|
||||
`created_at` DateTime64(3) DEFAULT now(),
|
||||
`updated_at` DateTime64(3) DEFAULT now(),
|
||||
INDEX idx_id id TYPE bloom_filter() GRANULARITY 1,
|
||||
INDEX idx_trace_id trace_id TYPE bloom_filter() GRANULARITY 1,
|
||||
INDEX idx_project_id project_id TYPE bloom_filter() GRANULARITY 1,
|
||||
INDEX idx_res_metadata_key mapKeys(metadata) TYPE bloom_filter() GRANULARITY 1,
|
||||
INDEX idx_res_metadata_value mapValues(metadata) TYPE bloom_filter() GRANULARITY 1
|
||||
) ENGINE = ReplacingMergeTree Partition by toYYYYMM(start_time)
|
||||
ORDER BY (
|
||||
project_id,
|
||||
`type`,
|
||||
trace_id,
|
||||
toUnixTimestamp(start_time),
|
||||
id
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE scores;
|
||||
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE scores (
|
||||
`id` String,
|
||||
`timestamp` DateTime64(3),
|
||||
`project_id` String,
|
||||
`trace_id` String,
|
||||
`observation_id` Nullable(String),
|
||||
`name` String,
|
||||
`value` Float64,
|
||||
`source` String,
|
||||
`comment` Nullable(String) CODEC(ZSTD(1)),
|
||||
`author_user_id` Nullable(String),
|
||||
`config_id` Nullable(String),
|
||||
`data_type` String,
|
||||
`string_value` Nullable(String),
|
||||
`created_at` DateTime64(3) DEFAULT now(),
|
||||
`updated_at` DateTime64(3) DEFAULT now(),
|
||||
INDEX idx_id id TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_project_id trace_id TYPE bloom_filter(0.001) GRANULARITY 1
|
||||
) ENGINE = ReplacingMergeTree Partition by toYYYYMM(timestamp)
|
||||
ORDER BY (
|
||||
project_id,
|
||||
trace_id,
|
||||
toUnixTimestamp(timestamp),
|
||||
id
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Load environment variables
|
||||
source ../../.env
|
||||
|
||||
# Check if golang-migrate is installed
|
||||
if ! command -v migrate &> /dev/null
|
||||
then
|
||||
echo "Error: golang-migrate is not installed or not in PATH."
|
||||
echo "Please install golang-migrate via 'brew install golang-migrate' to run this script."
|
||||
echo "Visit https://github.com/golang-migrate/migrate for more installation instructions."
|
||||
exit 1
|
||||
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"
|
||||
else
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&x-migrations-table-engine=MergeTree"
|
||||
fi
|
||||
# Execute the down command
|
||||
migrate -source file://clickhouse/migrations -database "$DATABASE_URL" down
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Load environment variables
|
||||
source ../../.env
|
||||
|
||||
# Check if golang-migrate is installed
|
||||
if ! command -v migrate &> /dev/null
|
||||
then
|
||||
echo "Error: golang-migrate is not installed or not in PATH."
|
||||
echo "Please install golang-migrate via 'brew install golang-migrate' to run this script."
|
||||
echo "Visit https://github.com/golang-migrate/migrate for more installation instructions."
|
||||
exit 1
|
||||
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"
|
||||
else
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&x-migrations-table-engine=MergeTree"
|
||||
fi
|
||||
# Execute the drop command
|
||||
migrate -source file://clickhouse/migrations -database "$DATABASE_URL" drop
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Load environment variables
|
||||
source ../../.env
|
||||
|
||||
# Check if golang-migrate is installed
|
||||
if ! command -v migrate &> /dev/null
|
||||
then
|
||||
echo "Error: golang-migrate is not installed or not in PATH."
|
||||
echo "Please install golang-migrate via 'brew install golang-migrate' to run this script."
|
||||
echo "Visit https://github.com/golang-migrate/migrate for more installation instructions."
|
||||
exit 1
|
||||
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"
|
||||
else
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&x-migrations-table-engine=MergeTree"
|
||||
fi
|
||||
# Execute the up command
|
||||
migrate -source file://clickhouse/migrations -database "$DATABASE_URL" up
|
||||
@@ -38,15 +38,22 @@
|
||||
"db:seed": "dotenv -e ../../.env -- npx prisma db seed",
|
||||
"db:generate": "dotenv -e ../../.env -- npx prisma generate",
|
||||
"db:seed:examples": "dotenv -e ../../.env -- npx prisma db seed -- --environment examples",
|
||||
"db:seed:load": "dotenv -e ../../.env -- npx prisma db seed -- --environment load"
|
||||
"db:seed:load": "dotenv -e ../../.env -- npx prisma db seed -- --environment load",
|
||||
"ch:status": "dotenv -e ../../.env -- goose -dir './clickhouse/migrations/' status",
|
||||
"ch:up": "bash clickhouse/scripts/up.sh",
|
||||
"ch:down": "bash clickhouse/scripts/down.sh",
|
||||
"ch:drop": "bash clickhouse/scripts/drop.sh",
|
||||
"ch:reset": "pnpm run ch:down && pnpm run ch:up"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "ts-node -r tsconfig-paths/register -r dotenv/config --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.550.0",
|
||||
"@anthropic-ai/tokenizer": "^0.0.4",
|
||||
"@aws-sdk/client-s3": "^3.627.0",
|
||||
"@aws-sdk/lib-storage": "^3.568.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.554.0",
|
||||
"@clickhouse/client": "^1.4.0",
|
||||
"@langchain/anthropic": "^0.1.21",
|
||||
"@langchain/core": "^0.1.61",
|
||||
"@langchain/openai": "^0.0.33",
|
||||
@@ -55,12 +62,17 @@
|
||||
"@react-email/render": "^0.0.15",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bullmq": "^5.4.2",
|
||||
"decimal.js": "^10.4.3",
|
||||
"exponential-backoff": "^3.1.1",
|
||||
"ioredis": "^5.4.1",
|
||||
"kysely": "^0.27.3",
|
||||
"langchain": "^0.2.6",
|
||||
"lodash": "^4.17.21",
|
||||
"next-auth": "^4.24.7",
|
||||
"nodemailer": "^6.9.13",
|
||||
"prisma-extension-kysely": "^2.1.0",
|
||||
"uuid": "^9.0.1",
|
||||
"zod": "^3.23.8",
|
||||
"zod-to-json-schema": "^3.22.5"
|
||||
},
|
||||
|
||||
@@ -4,13 +4,14 @@ export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
|
||||
: ColumnType<T, T | undefined, T>;
|
||||
export type Timestamp = ColumnType<Date, Date | string, Date | string>;
|
||||
|
||||
export const ProjectRole = {
|
||||
export const Role = {
|
||||
OWNER: "OWNER",
|
||||
ADMIN: "ADMIN",
|
||||
MEMBER: "MEMBER",
|
||||
VIEWER: "VIEWER"
|
||||
VIEWER: "VIEWER",
|
||||
NONE: "NONE"
|
||||
} as const;
|
||||
export type ProjectRole = (typeof ProjectRole)[keyof typeof ProjectRole];
|
||||
export type Role = (typeof Role)[keyof typeof Role];
|
||||
export const ObservationType = {
|
||||
SPAN: "SPAN",
|
||||
EVENT: "EVENT",
|
||||
@@ -90,8 +91,10 @@ export type AuditLog = {
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
user_id: string;
|
||||
project_id: string;
|
||||
user_project_role: ProjectRole;
|
||||
org_id: string;
|
||||
user_org_role: string;
|
||||
project_id: string | null;
|
||||
user_project_role: string | null;
|
||||
resource_type: string;
|
||||
resource_id: string;
|
||||
action: string;
|
||||
@@ -121,15 +124,16 @@ export type CronJobs = {
|
||||
};
|
||||
export type Dataset = {
|
||||
id: string;
|
||||
project_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
metadata: unknown | null;
|
||||
project_id: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
};
|
||||
export type DatasetItem = {
|
||||
id: string;
|
||||
project_id: string;
|
||||
status: Generated<DatasetStatus>;
|
||||
input: unknown | null;
|
||||
expected_output: unknown | null;
|
||||
@@ -142,6 +146,7 @@ export type DatasetItem = {
|
||||
};
|
||||
export type DatasetRunItems = {
|
||||
id: string;
|
||||
project_id: string;
|
||||
dataset_run_id: string;
|
||||
dataset_item_id: string;
|
||||
trace_id: string;
|
||||
@@ -151,6 +156,7 @@ export type DatasetRunItems = {
|
||||
};
|
||||
export type DatasetRuns = {
|
||||
id: string;
|
||||
project_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
metadata: unknown | null;
|
||||
@@ -226,9 +232,11 @@ export type LlmApiKeys = {
|
||||
export type MembershipInvitation = {
|
||||
id: string;
|
||||
email: string;
|
||||
role: ProjectRole;
|
||||
project_id: string;
|
||||
sender_id: string | null;
|
||||
org_id: string;
|
||||
org_role: Role;
|
||||
project_id: string | null;
|
||||
project_role: Role | null;
|
||||
invited_by_user_id: string | null;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
};
|
||||
@@ -306,6 +314,8 @@ export type ObservationView = {
|
||||
unit: string | null;
|
||||
completion_start_time: Timestamp | null;
|
||||
prompt_id: string | null;
|
||||
prompt_name: string | null;
|
||||
prompt_version: number | null;
|
||||
model_id: string | null;
|
||||
input_price: string | null;
|
||||
output_price: string | null;
|
||||
@@ -316,6 +326,21 @@ export type ObservationView = {
|
||||
latency: number | null;
|
||||
time_to_first_token: number | null;
|
||||
};
|
||||
export type Organization = {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
cloud_config: unknown | null;
|
||||
};
|
||||
export type OrganizationMembership = {
|
||||
id: string;
|
||||
org_id: string;
|
||||
user_id: string;
|
||||
role: Role;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
};
|
||||
export type PosthogIntegration = {
|
||||
project_id: string;
|
||||
encrypted_posthog_api_key: string;
|
||||
@@ -326,15 +351,16 @@ export type PosthogIntegration = {
|
||||
};
|
||||
export type Project = {
|
||||
id: string;
|
||||
org_id: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
name: string;
|
||||
cloud_config: unknown | null;
|
||||
};
|
||||
export type ProjectMembership = {
|
||||
org_membership_id: string;
|
||||
project_id: string;
|
||||
user_id: string;
|
||||
role: ProjectRole;
|
||||
role: Role;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
};
|
||||
@@ -479,6 +505,8 @@ export type DB = {
|
||||
models: Model;
|
||||
observations: Observation;
|
||||
observations_view: ObservationView;
|
||||
organization_memberships: OrganizationMembership;
|
||||
organizations: Organization;
|
||||
posthog_integrations: PosthogIntegration;
|
||||
project_memberships: ProjectMembership;
|
||||
projects: Project;
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
-- Add project_id to datasets, dataset_items, dataset_runs, dataset_run_items
|
||||
ALTER TABLE "dataset_items"
|
||||
ADD COLUMN "project_id" TEXT;
|
||||
|
||||
ALTER TABLE "dataset_run_items"
|
||||
ADD COLUMN "project_id" TEXT;
|
||||
|
||||
ALTER TABLE "dataset_runs"
|
||||
ADD COLUMN "project_id" TEXT;
|
||||
|
||||
-- Backfill project_id for dataset_items
|
||||
UPDATE dataset_items
|
||||
SET project_id = datasets.project_id
|
||||
FROM datasets
|
||||
WHERE dataset_items.dataset_id = datasets.id;
|
||||
|
||||
-- Backfill project_id for dataset_runs
|
||||
UPDATE dataset_runs
|
||||
SET project_id = datasets.project_id
|
||||
FROM datasets
|
||||
WHERE dataset_runs.dataset_id = datasets.id;
|
||||
|
||||
-- Backfill project_id for dataset_run_items
|
||||
UPDATE dataset_run_items
|
||||
SET project_id = dataset_runs.project_id
|
||||
FROM dataset_runs
|
||||
WHERE dataset_run_items.dataset_run_id = dataset_runs.id;
|
||||
|
||||
-- Drop the old foreign keys
|
||||
ALTER TABLE "dataset_run_items"
|
||||
DROP CONSTRAINT "dataset_run_items_dataset_item_id_fkey",
|
||||
DROP CONSTRAINT "dataset_run_items_dataset_run_id_fkey";
|
||||
|
||||
ALTER TABLE "dataset_items"
|
||||
DROP CONSTRAINT "dataset_items_dataset_id_fkey";
|
||||
|
||||
ALTER TABLE "dataset_runs"
|
||||
DROP CONSTRAINT "dataset_runs_dataset_id_fkey";
|
||||
|
||||
-- Now alter the columns to NOT NULL and update primary keys
|
||||
ALTER TABLE "datasets"
|
||||
DROP CONSTRAINT "datasets_pkey",
|
||||
ADD CONSTRAINT "datasets_pkey" PRIMARY KEY ("id", "project_id");
|
||||
|
||||
ALTER TABLE "dataset_items"
|
||||
ALTER COLUMN "project_id" SET NOT NULL,
|
||||
DROP CONSTRAINT "dataset_items_pkey",
|
||||
ADD CONSTRAINT "dataset_items_pkey" PRIMARY KEY ("id", "project_id");
|
||||
|
||||
ALTER TABLE "dataset_runs"
|
||||
ALTER COLUMN "project_id" SET NOT NULL,
|
||||
DROP CONSTRAINT "dataset_runs_pkey",
|
||||
ADD CONSTRAINT "dataset_runs_pkey" PRIMARY KEY ("id", "project_id");
|
||||
|
||||
ALTER TABLE "dataset_run_items"
|
||||
ALTER COLUMN "project_id" SET NOT NULL,
|
||||
DROP CONSTRAINT "dataset_run_items_pkey",
|
||||
ADD CONSTRAINT "dataset_run_items_pkey" PRIMARY KEY ("id", "project_id");
|
||||
|
||||
|
||||
-- Add new foreign keys
|
||||
ALTER TABLE "dataset_items" ADD CONSTRAINT "dataset_items_dataset_id_project_id_fkey" FOREIGN KEY ("dataset_id", "project_id") REFERENCES "datasets"("id", "project_id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "dataset_runs" ADD CONSTRAINT "dataset_runs_dataset_id_project_id_fkey" FOREIGN KEY ("dataset_id", "project_id") REFERENCES "datasets"("id", "project_id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "dataset_run_items" ADD CONSTRAINT "dataset_run_items_dataset_run_id_project_id_fkey" FOREIGN KEY ("dataset_run_id", "project_id") REFERENCES "dataset_runs"("id", "project_id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "dataset_run_items" ADD CONSTRAINT "dataset_run_items_dataset_item_id_project_id_fkey" FOREIGN KEY ("dataset_item_id", "project_id") REFERENCES "dataset_items"("id", "project_id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX CONCURRENTLY "dataset_runs_dataset_id_project_id_name_key" ON "dataset_runs"("dataset_id", "project_id", "name");
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- DropIndex
|
||||
DROP INDEX CONCURRENTLY "dataset_runs_dataset_id_name_key";
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
DROP VIEW IF EXISTS "observations_view"; -- Drop view as column was added in 20240705154048_observation_view_add_created_at_updated_at and update view must have same columns
|
||||
CREATE VIEW "observations_view" AS -- Specify the columns that should be returned in the view, as calculated columns are added but exist in the observations table already
|
||||
SELECT
|
||||
o.id,
|
||||
o.name,
|
||||
o.start_time,
|
||||
o.end_time,
|
||||
o.parent_observation_id,
|
||||
o.type,
|
||||
o.trace_id,
|
||||
o.metadata,
|
||||
o.model,
|
||||
o."modelParameters",
|
||||
o.input,
|
||||
o.output,
|
||||
o.level,
|
||||
o.status_message,
|
||||
o.completion_start_time,
|
||||
o.completion_tokens,
|
||||
o.prompt_tokens,
|
||||
o.total_tokens,
|
||||
o.version,
|
||||
o.project_id,
|
||||
o.created_at,
|
||||
o.updated_at,
|
||||
o.unit,
|
||||
o.prompt_id,
|
||||
p.name as prompt_name, -- added in this change
|
||||
p.version as prompt_version, -- added in this change
|
||||
o.input_cost,
|
||||
o.output_cost,
|
||||
o.total_cost,
|
||||
o.internal_model,
|
||||
m.id AS "model_id",
|
||||
m.start_date AS "model_start_date",
|
||||
m.input_price,
|
||||
m.output_price,
|
||||
m.total_price,
|
||||
m.tokenizer_config AS "tokenizer_config",
|
||||
CASE
|
||||
WHEN o.calculated_input_cost IS NULL AND o.input_cost IS NULL AND o.output_cost IS NULL AND o.total_cost IS NULL THEN
|
||||
o.prompt_tokens::decimal * m.input_price
|
||||
ELSE
|
||||
COALESCE(o.calculated_input_cost, o.input_cost)
|
||||
END AS "calculated_input_cost",
|
||||
CASE
|
||||
WHEN o.calculated_output_cost IS NULL AND o.input_cost IS NULL AND o.output_cost IS NULL AND o.total_cost IS NULL THEN
|
||||
o.completion_tokens::decimal * m.output_price
|
||||
ELSE
|
||||
COALESCE(o.calculated_output_cost, o.output_cost)
|
||||
END AS "calculated_output_cost",
|
||||
CASE
|
||||
WHEN o.calculated_total_cost IS NULL AND o.input_cost IS NULL AND o.output_cost IS NULL AND o.total_cost IS NULL THEN
|
||||
CASE
|
||||
WHEN m.total_price IS NOT NULL AND o.total_tokens IS NOT NULL THEN
|
||||
m.total_price * o.total_tokens
|
||||
ELSE
|
||||
o.prompt_tokens::decimal * m.input_price +
|
||||
o.completion_tokens::decimal * m.output_price
|
||||
END
|
||||
ELSE
|
||||
COALESCE(o.calculated_total_cost, o.total_cost)
|
||||
END AS "calculated_total_cost",
|
||||
CASE WHEN o.end_time IS NULL THEN NULL ELSE (EXTRACT(EPOCH FROM o."end_time") - EXTRACT(EPOCH FROM o."start_time"))::double precision END AS "latency",
|
||||
CASE WHEN o.completion_start_time IS NOT NULL AND o.start_time IS NOT NULL THEN EXTRACT(EPOCH FROM (completion_start_time - start_time))::double precision ELSE NULL END as "time_to_first_token"
|
||||
|
||||
FROM
|
||||
observations o
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
models.*
|
||||
FROM
|
||||
models
|
||||
WHERE (models.project_id = o.project_id OR models.project_id IS NULL)
|
||||
AND models.model_name = o.internal_model
|
||||
AND (models.start_date < o.start_time OR models.start_date IS NULL)
|
||||
AND o.unit::TEXT = models.unit
|
||||
ORDER BY
|
||||
models.project_id ASC, -- in postgres, NULLs are sorted last when ordering ASC
|
||||
models.start_date DESC NULLS LAST -- now, NULLs are sorted last when ordering DESC as well
|
||||
LIMIT 1
|
||||
) m ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
prompts.*
|
||||
FROM
|
||||
prompts
|
||||
WHERE prompts.id = o.prompt_id
|
||||
AND prompts.project_id = o.project_id
|
||||
LIMIT 1
|
||||
) p ON TRUE
|
||||
|
||||
|
||||
-- requirements:
|
||||
-- 1. The view should return all columns from the observations table
|
||||
-- 2. The view should match with only one model for each observation if:
|
||||
-- a. The model has the same project_id as the observation, otherwise the model without project_id.
|
||||
-- b. The model has the same model_name as the observation
|
||||
-- c. The model has a start_date that is less than the observation start_time, otherwise the model without start_date
|
||||
-- d. The model has the same unit as the observation
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
|
||||
INSERT INTO models (
|
||||
id,
|
||||
project_id,
|
||||
model_name,
|
||||
match_pattern,
|
||||
start_date,
|
||||
input_price,
|
||||
output_price,
|
||||
total_price,
|
||||
unit,
|
||||
tokenizer_id,
|
||||
tokenizer_config
|
||||
)
|
||||
VALUES
|
||||
-- gpt-4o-2024-08-06
|
||||
('clzjr85f70000ymmzg7hqffra', NULL, 'gpt-4o-2024-08-06', '(?i)^(gpt-4o-2024-08-06)$', NULL, 0.0000025, 0.000010, NULL, 'TOKENS', 'openai', '{ "tokensPerMessage": 3, "tokensPerName": 1, "tokenizerModel": "gpt-4o" }')
|
||||
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
-- Table ORGANIZATIONS
|
||||
-- Create empty table ORGANIZATIONS
|
||||
CREATE TABLE "organizations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"cloud_config" JSONB,
|
||||
|
||||
CONSTRAINT "organizations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
-- Backfill table ORGANIZATIONS: create a new organization for each project, move cloudConfig, and set the org_id on the project to project_id prefixed with 'o'
|
||||
INSERT INTO "organizations" ("id", "name", "cloud_config", "created_at")
|
||||
SELECT
|
||||
CONCAT('o', "id") as "id", -- This mapping is used in other migration steps as well, keep it consistent
|
||||
"name",
|
||||
"cloud_config",
|
||||
"created_at"
|
||||
FROM "projects";
|
||||
-- Drop column cloud_config from projects as it's now on organization level
|
||||
ALTER TABLE "projects" DROP COLUMN "cloud_config";
|
||||
|
||||
|
||||
-- PROJECT.ORG_ID
|
||||
-- Add org_id to projects
|
||||
ALTER TABLE "projects" ADD COLUMN "org_id" TEXT;
|
||||
-- Backfill: org_id on projects, set it to project_id prefixed with 'o'
|
||||
UPDATE "projects"
|
||||
SET "org_id" = CONCAT('o', "id");
|
||||
-- Set not null after backfill
|
||||
ALTER TABLE "projects" ALTER COLUMN "org_id" SET NOT NULL;
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "projects" ADD CONSTRAINT "projects_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
-- CreateIndex
|
||||
CREATE INDEX "projects_org_id_idx" ON "projects"("org_id");
|
||||
|
||||
|
||||
-- ORGANIZATION MEMBERSHIPS
|
||||
-- Create UserRole ENUM
|
||||
CREATE TYPE "Role" AS ENUM ('OWNER', 'ADMIN', 'MEMBER', 'VIEWER', 'NONE');
|
||||
-- Create empty table
|
||||
CREATE TABLE "organization_memberships" (
|
||||
"id" TEXT NOT NULL,
|
||||
"org_id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"role" "Role" NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "organization_memberships_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
-- Add indexes
|
||||
CREATE INDEX "organization_memberships_user_id_idx" ON "organization_memberships"("user_id");
|
||||
CREATE INDEX "organization_memberships_org_id_idx" ON "organization_memberships"("org_id");
|
||||
CREATE UNIQUE INDEX "organization_memberships_org_id_user_id_key" ON "organization_memberships"("org_id", "user_id");
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "organization_memberships" ADD CONSTRAINT "organization_memberships_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "organization_memberships" ADD CONSTRAINT "organization_memberships_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
|
||||
|
||||
-- Migrate project memberships to organization memberships
|
||||
INSERT INTO "organization_memberships" ("id", "org_id", "user_id", "role", "created_at", "updated_at")
|
||||
SELECT
|
||||
md5(random()::text || clock_timestamp()::text || project_id::text || user_id::text)::uuid AS "id",
|
||||
CONCAT('o', "project_id") as "org_id",
|
||||
"user_id",
|
||||
"role"::text::"Role" as "role",
|
||||
"created_at",
|
||||
"updated_at"
|
||||
FROM "project_memberships";
|
||||
|
||||
|
||||
|
||||
-- Delete all project memberships after migration to organization memberships
|
||||
DELETE FROM "project_memberships";
|
||||
|
||||
-- Add org_membership_id to project_memberships, ok to be not null as it's a new column on a now empty table
|
||||
ALTER TABLE "project_memberships" ADD COLUMN "org_membership_id" TEXT NOT NULL;
|
||||
-- Switch to new UserRole enum
|
||||
ALTER TABLE "project_memberships" DROP COLUMN "role",
|
||||
ADD COLUMN "role" "Role" NOT NULL;
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "project_memberships" ADD CONSTRAINT "project_memberships_org_membership_id_fkey" FOREIGN KEY ("org_membership_id") REFERENCES "organization_memberships"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
-- CreateIndex
|
||||
CREATE INDEX "project_memberships_project_id_idx" ON "project_memberships"("project_id");
|
||||
-- CreateIndex
|
||||
CREATE INDEX "project_memberships_org_membership_id_idx" ON "project_memberships"("org_membership_id");
|
||||
|
||||
|
||||
|
||||
-- AUDIT LOGS
|
||||
-- DropForeignKey, just index on these, should remain after project/org/user deletions
|
||||
ALTER TABLE "audit_logs" DROP CONSTRAINT "audit_logs_user_id_fkey";
|
||||
ALTER TABLE "audit_logs" DROP CONSTRAINT "audit_logs_project_id_fkey";
|
||||
-- Drop not null on project level cols
|
||||
ALTER TABLE "audit_logs" ALTER COLUMN "project_id" DROP NOT NULL;
|
||||
ALTER TABLE "audit_logs" ALTER COLUMN "user_project_role" DROP NOT NULL;
|
||||
-- Add org-level cols
|
||||
ALTER TABLE "audit_logs" ADD COLUMN "org_id" TEXT;
|
||||
ALTER TABLE "audit_logs" ADD COLUMN "user_org_role" TEXT;
|
||||
-- Backfill org_id
|
||||
UPDATE "audit_logs"
|
||||
SET "org_id" = "projects"."org_id"
|
||||
FROM "projects"
|
||||
WHERE "audit_logs"."project_id" = "projects"."id";
|
||||
-- Backfill user_org_role with value from user_project_role
|
||||
UPDATE "audit_logs"
|
||||
SET "user_org_role" = "user_project_role"::text::"Role";
|
||||
-- Drop and recreate user_project_role column as text column going forward, empty for historical data as it's all on org level now
|
||||
ALTER TABLE "audit_logs" DROP COLUMN "user_project_role";
|
||||
ALTER TABLE "audit_logs" ADD COLUMN "user_project_role" TEXT; -- nullable
|
||||
-- Add not null on org level cols
|
||||
ALTER TABLE "audit_logs" ALTER COLUMN "org_id" SET NOT NULL;
|
||||
ALTER TABLE "audit_logs" ALTER COLUMN "user_org_role" SET NOT NULL;
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audit_logs_user_id_idx" ON "audit_logs"("user_id");
|
||||
CREATE INDEX "audit_logs_org_id_idx" ON "audit_logs"("org_id");
|
||||
|
||||
|
||||
-- MEMBERSHIP INVITATIONS
|
||||
-- Rename sender_id to invited_by_user_id
|
||||
ALTER TABLE "membership_invitations" DROP CONSTRAINT "membership_invitations_sender_id_fkey";
|
||||
ALTER TABLE "membership_invitations" RENAME COLUMN "sender_id" TO "invited_by_user_id";
|
||||
ALTER TABLE "membership_invitations" ADD CONSTRAINT "membership_invitations_invited_by_user_id_fkey" FOREIGN KEY ("invited_by_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "membership_invitations" DROP CONSTRAINT "membership_invitations_project_id_fkey";
|
||||
-- AlterTable
|
||||
ALTER TABLE "membership_invitations" RENAME COLUMN "role" TO "project_role";
|
||||
ALTER TABLE "membership_invitations" ADD COLUMN "org_id" TEXT;
|
||||
ALTER TABLE "membership_invitations" ADD COLUMN "org_role" "Role";
|
||||
ALTER TABLE "membership_invitations" ALTER COLUMN "project_id" DROP NOT NULL;
|
||||
ALTER TABLE "membership_invitations" ALTER COLUMN "project_role" DROP NOT NULL;
|
||||
-- Backfill org id
|
||||
UPDATE "membership_invitations"
|
||||
SET "org_id" = "projects"."org_id"
|
||||
FROM "projects"
|
||||
WHERE "membership_invitations"."project_id" = "projects"."id";
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "membership_invitations" ADD CONSTRAINT "membership_invitations_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
-- Backfill org role with value from project role
|
||||
UPDATE "membership_invitations"
|
||||
SET "org_role" = "project_role"::text::"Role";
|
||||
-- Set project-level cols to null, as it's now org level for all existing invitations and role enum will change below
|
||||
UPDATE "membership_invitations"
|
||||
SET "project_role" = NULL, "project_id" = NULL;
|
||||
-- Switch to new UserRole enum
|
||||
ALTER TABLE "membership_invitations" DROP COLUMN "project_role",
|
||||
ADD COLUMN "project_role" "Role";
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "membership_invitations" ADD CONSTRAINT "membership_invitations_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- Add not null on org level cols after backfill
|
||||
ALTER TABLE "membership_invitations"
|
||||
ALTER COLUMN "org_id" SET NOT NULL,
|
||||
ALTER COLUMN "org_role" SET NOT NULL;
|
||||
-- CreateIndex
|
||||
CREATE INDEX "membership_invitations_org_id_idx" ON "membership_invitations"("org_id");
|
||||
|
||||
|
||||
-- Drop ProjectRole enum as it is replaced by Role
|
||||
DROP TYPE "ProjectRole";
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
|
||||
-- Fix model name
|
||||
-- https://github.com/langfuse/langfuse/issues/2688
|
||||
|
||||
UPDATE models
|
||||
SET model_name = 'text-embedding-3-large'
|
||||
WHERE id = 'clruwn76700020al7gp8e4g4l'
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "dataset_items" DROP CONSTRAINT "dataset_items_source_observation_id_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "dataset_items" DROP CONSTRAINT "dataset_items_source_trace_id_fkey";
|
||||
@@ -63,21 +63,21 @@ model Session {
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
name String?
|
||||
email String? @unique
|
||||
emailVerified DateTime? @map("email_verified")
|
||||
password String?
|
||||
image String?
|
||||
admin Boolean @default(false)
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
projectMemberships ProjectMembership[]
|
||||
invitations MembershipInvitation[]
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
featureFlags String[] @default([]) @map("feature_flags")
|
||||
AuditLog AuditLog[]
|
||||
id String @id @default(cuid())
|
||||
name String?
|
||||
email String? @unique
|
||||
emailVerified DateTime? @map("email_verified")
|
||||
password String?
|
||||
image String?
|
||||
admin Boolean @default(false)
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
organizationMemberships OrganizationMembership[]
|
||||
projectMemberships ProjectMembership[]
|
||||
invitations MembershipInvitation[]
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
featureFlags String[] @default([]) @map("feature_flags")
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -91,13 +91,27 @@ model VerificationToken {
|
||||
@@map("verification_tokens")
|
||||
}
|
||||
|
||||
model Organization {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
cloudConfig Json? @map("cloud_config") // Langfuse Cloud, for zod schema see @/src/features/organizations/utils/cloudConfigSchema
|
||||
organizationMemberships OrganizationMembership[]
|
||||
projects Project[]
|
||||
MembershipInvitation MembershipInvitation[]
|
||||
|
||||
@@map("organizations")
|
||||
}
|
||||
|
||||
model Project {
|
||||
id String @id @default(cuid())
|
||||
orgId String @map("org_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
name String
|
||||
cloudConfig Json? @map("cloud_config") // Langfuse Cloud, for zod schema see projectsRouter.ts
|
||||
projectMembers ProjectMembership[]
|
||||
organization Organization @relation(fields: [orgId], references: [id], onUpdate: Cascade, onDelete: Cascade)
|
||||
traces Trace[]
|
||||
observations Observation[]
|
||||
apiKeys ApiKey[]
|
||||
@@ -107,7 +121,6 @@ model Project {
|
||||
sessions TraceSession[]
|
||||
Prompt Prompt[]
|
||||
Model Model[]
|
||||
AuditLog AuditLog[]
|
||||
EvalTemplate EvalTemplate[]
|
||||
JobConfiguration JobConfiguration[]
|
||||
JobExecution JobExecution[]
|
||||
@@ -117,6 +130,7 @@ model Project {
|
||||
scoreConfig ScoreConfig[]
|
||||
BatchExport BatchExport[]
|
||||
|
||||
@@index([orgId])
|
||||
@@map("projects")
|
||||
}
|
||||
|
||||
@@ -161,41 +175,68 @@ model LlmApiKeys {
|
||||
@@map("llm_api_keys")
|
||||
}
|
||||
|
||||
model OrganizationMembership {
|
||||
id String @id @default(cuid())
|
||||
orgId String @map("org_id")
|
||||
organization Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
userId String @map("user_id")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
role Role @map("role")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
ProjectMemberships ProjectMembership[]
|
||||
|
||||
@@unique([orgId, userId])
|
||||
@@index([userId])
|
||||
@@index([orgId])
|
||||
@@map("organization_memberships")
|
||||
}
|
||||
|
||||
// Set a project-specific role for a user in an organization
|
||||
model ProjectMembership {
|
||||
projectId String @map("project_id")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
userId String @map("user_id")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
role ProjectRole
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
orgMembershipId String @map("org_membership_id")
|
||||
organizationMembership OrganizationMembership @relation(fields: [orgMembershipId], references: [id], onDelete: Cascade)
|
||||
projectId String @map("project_id")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
userId String @map("user_id")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
role Role
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
|
||||
@@id([projectId, userId])
|
||||
@@index([userId])
|
||||
@@index([projectId])
|
||||
@@index([orgMembershipId])
|
||||
@@map("project_memberships")
|
||||
}
|
||||
|
||||
model MembershipInvitation {
|
||||
id String @id @unique @default(cuid())
|
||||
email String
|
||||
role ProjectRole
|
||||
projectId String @map("project_id")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
senderId String? @map("sender_id")
|
||||
sender User? @relation(fields: [senderId], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
id String @id @unique @default(cuid())
|
||||
email String
|
||||
orgId String @map("org_id")
|
||||
organization Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
orgRole Role @map("org_role")
|
||||
projectId String? @map("project_id")
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
||||
projectRole Role? @map("project_role")
|
||||
invitedByUserId String? @map("invited_by_user_id")
|
||||
invitedByUser User? @relation(fields: [invitedByUserId], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
|
||||
@@index([projectId])
|
||||
@@index([orgId])
|
||||
@@index([email])
|
||||
@@map("membership_invitations")
|
||||
}
|
||||
|
||||
enum ProjectRole {
|
||||
enum Role {
|
||||
OWNER
|
||||
ADMIN
|
||||
MEMBER
|
||||
VIEWER
|
||||
NONE
|
||||
}
|
||||
|
||||
model TraceSession {
|
||||
@@ -238,7 +279,6 @@ model Trace {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
|
||||
DatasetItem DatasetItem[]
|
||||
JobExecution JobExecution[]
|
||||
|
||||
@@index([projectId])
|
||||
@@ -323,9 +363,8 @@ model Observation {
|
||||
calculatedOutputCost Decimal? @map("calculated_output_cost")
|
||||
calculatedTotalCost Decimal? @map("calculated_total_cost")
|
||||
|
||||
completionStartTime DateTime? @map("completion_start_time")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
derivedDatasetItems DatasetItem[]
|
||||
completionStartTime DateTime? @map("completion_start_time")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
|
||||
promptId String? @map("prompt_id") // no fk constraint, prompt can be deleted
|
||||
|
||||
@@ -376,7 +415,10 @@ view ObservationView {
|
||||
unit String?
|
||||
completionStartTime DateTime? @map("completion_start_time")
|
||||
|
||||
promptId String? @map("prompt_id")
|
||||
// prompts
|
||||
promptId String? @map("prompt_id")
|
||||
promptName String? @map("prompt_name")
|
||||
promptVersion Int? @map("prompt_version")
|
||||
|
||||
// model fields
|
||||
modelId String? @map("model_id")
|
||||
@@ -488,17 +530,18 @@ model CronJobs {
|
||||
}
|
||||
|
||||
model Dataset {
|
||||
id String @id @default(cuid())
|
||||
id String @default(cuid())
|
||||
projectId String @map("project_id")
|
||||
name String
|
||||
description String?
|
||||
metadata Json?
|
||||
projectId String @map("project_id")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
datasetItems DatasetItem[]
|
||||
datasetRuns DatasetRuns[]
|
||||
|
||||
@@id([id, projectId])
|
||||
@@unique([projectId, name])
|
||||
@@index([projectId], type: Hash)
|
||||
@@index([createdAt])
|
||||
@@ -507,21 +550,21 @@ model Dataset {
|
||||
}
|
||||
|
||||
model DatasetItem {
|
||||
id String @id @default(cuid())
|
||||
id String @default(cuid())
|
||||
projectId String @map("project_id")
|
||||
status DatasetStatus @default(ACTIVE)
|
||||
input Json?
|
||||
expectedOutput Json? @map("expected_output")
|
||||
metadata Json?
|
||||
sourceTraceId String? @map("source_trace_id")
|
||||
sourceTrace Trace? @relation(fields: [sourceTraceId], references: [id], onDelete: SetNull)
|
||||
sourceObservationId String? @map("source_observation_id")
|
||||
sourceObservation Observation? @relation(fields: [sourceObservationId], references: [id], onDelete: SetNull)
|
||||
datasetId String @map("dataset_id")
|
||||
dataset Dataset @relation(fields: [datasetId], references: [id], onDelete: Cascade)
|
||||
dataset Dataset @relation(fields: [datasetId, projectId], references: [id, projectId], onDelete: Cascade)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
datasetRunItems DatasetRunItems[]
|
||||
|
||||
@@id([id, projectId])
|
||||
@@index([sourceTraceId], type: Hash)
|
||||
@@index([sourceObservationId], type: Hash)
|
||||
@@index([datasetId], type: Hash)
|
||||
@@ -536,17 +579,19 @@ enum DatasetStatus {
|
||||
}
|
||||
|
||||
model DatasetRuns {
|
||||
id String @id @default(cuid())
|
||||
id String @default(cuid())
|
||||
projectId String @map("project_id")
|
||||
name String
|
||||
description String?
|
||||
metadata Json?
|
||||
datasetId String @map("dataset_id")
|
||||
dataset Dataset @relation(fields: [datasetId], references: [id], onDelete: Cascade)
|
||||
dataset Dataset @relation(fields: [datasetId, projectId], references: [id, projectId], onDelete: Cascade)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
datasetRunItems DatasetRunItems[]
|
||||
|
||||
@@unique([datasetId, name])
|
||||
@@id([id, projectId])
|
||||
@@unique([datasetId, projectId, name])
|
||||
@@index([datasetId], type: Hash)
|
||||
@@index([createdAt])
|
||||
@@index([updatedAt])
|
||||
@@ -554,16 +599,18 @@ model DatasetRuns {
|
||||
}
|
||||
|
||||
model DatasetRunItems {
|
||||
id String @id @default(cuid())
|
||||
id String @default(cuid())
|
||||
projectId String @map("project_id")
|
||||
datasetRunId String @map("dataset_run_id")
|
||||
datasetRun DatasetRuns @relation(fields: [datasetRunId], references: [id], onDelete: Cascade)
|
||||
datasetRun DatasetRuns @relation(fields: [datasetRunId, projectId], references: [id, projectId], onDelete: Cascade)
|
||||
datasetItemId String @map("dataset_item_id")
|
||||
datasetItem DatasetItem @relation(fields: [datasetItemId], references: [id], onDelete: Cascade)
|
||||
datasetItem DatasetItem @relation(fields: [datasetItemId, projectId], references: [id, projectId], onDelete: Cascade)
|
||||
traceId String @map("trace_id")
|
||||
observationId String? @map("observation_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
|
||||
@@id([id, projectId])
|
||||
@@index([datasetRunId], type: Hash)
|
||||
@@index([datasetItemId], type: Hash)
|
||||
@@index([observationId], type: Hash)
|
||||
@@ -643,22 +690,25 @@ model Model {
|
||||
@@map("models")
|
||||
}
|
||||
|
||||
// No FK constraints to preserve audit logs
|
||||
model AuditLog {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
userId String @map("user_id")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
projectId String @map("project_id")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
userProjectRole ProjectRole @map("user_project_role")
|
||||
resourceType String @map("resource_type")
|
||||
resourceId String @map("resource_id")
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
userId String @map("user_id")
|
||||
orgId String @map("org_id")
|
||||
userOrgRole String @map("user_org_role")
|
||||
projectId String? @map("project_id")
|
||||
userProjectRole String? @map("user_project_role")
|
||||
resourceType String @map("resource_type")
|
||||
resourceId String @map("resource_id")
|
||||
action String
|
||||
before String? //stringified JSON
|
||||
after String? // stringified JSON
|
||||
|
||||
@@index([projectId])
|
||||
@@index([userId])
|
||||
@@index([orgId])
|
||||
@@index([createdAt])
|
||||
@@index([updatedAt])
|
||||
@@map("audit_logs")
|
||||
|
||||
+119
-16
@@ -14,6 +14,7 @@ import { v4 } from "uuid";
|
||||
import { ModelUsageUnit } from "../src";
|
||||
import { getDisplaySecretKey, hashSecretKey } from "../src/server";
|
||||
import { encrypt } from "../src/encryption";
|
||||
import { redis } from "../src/server/redis/redis";
|
||||
|
||||
const LOAD_TRACE_VOLUME = 10_000;
|
||||
|
||||
@@ -33,39 +34,111 @@ async function main() {
|
||||
options,
|
||||
}).values.environment;
|
||||
|
||||
const seedOrgId = "seed-org-id";
|
||||
const seedProjectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
|
||||
const seedUserId1 = "user-1"; // Owner of org
|
||||
const seedUserId2 = "user-2"; // Member of org, admin of project
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: { id: "user-1" },
|
||||
where: { id: seedUserId1 },
|
||||
update: {
|
||||
name: "Demo User",
|
||||
email: "demo@langfuse.com",
|
||||
password: await hash("password", 12),
|
||||
},
|
||||
create: {
|
||||
id: "user-1",
|
||||
id: seedUserId1,
|
||||
name: "Demo User",
|
||||
email: "demo@langfuse.com",
|
||||
password: await hash("password", 12),
|
||||
image: "https://static.langfuse.com/langfuse-dev%2Fexample-avatar.png",
|
||||
},
|
||||
});
|
||||
const user2 = await prisma.user.upsert({
|
||||
where: { id: seedUserId2 },
|
||||
update: {
|
||||
name: "Demo User 2",
|
||||
email: "member@langfuse.com",
|
||||
password: await hash("password", 12),
|
||||
},
|
||||
create: {
|
||||
id: seedUserId2,
|
||||
name: "Demo User 2",
|
||||
email: "member@langfuse.com",
|
||||
password: await hash("password", 12),
|
||||
},
|
||||
});
|
||||
|
||||
const seedProjectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
|
||||
await prisma.organization.upsert({
|
||||
where: { id: seedOrgId },
|
||||
update: {
|
||||
name: "Seed Org",
|
||||
},
|
||||
create: {
|
||||
id: seedOrgId,
|
||||
name: "Seed Org",
|
||||
},
|
||||
});
|
||||
|
||||
const project1 = await prisma.project.upsert({
|
||||
where: { id: seedProjectId },
|
||||
update: {
|
||||
name: "llm-app",
|
||||
orgId: seedOrgId,
|
||||
},
|
||||
create: {
|
||||
id: seedProjectId,
|
||||
name: "llm-app",
|
||||
projectMembers: {
|
||||
create: {
|
||||
role: "OWNER",
|
||||
userId: user.id,
|
||||
},
|
||||
orgId: seedOrgId,
|
||||
},
|
||||
});
|
||||
|
||||
const orgMembership = await prisma.organizationMembership.upsert({
|
||||
where: {
|
||||
orgId_userId: {
|
||||
userId: user.id,
|
||||
orgId: seedOrgId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
userId: user.id,
|
||||
orgId: seedOrgId,
|
||||
role: "OWNER",
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
const orgMembership2 = await prisma.organizationMembership.upsert({
|
||||
where: {
|
||||
orgId_userId: {
|
||||
userId: user2.id,
|
||||
orgId: seedOrgId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
userId: user2.id,
|
||||
orgId: seedOrgId,
|
||||
role: "MEMBER",
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
const projectMembership = await prisma.projectMembership.upsert({
|
||||
where: {
|
||||
projectId_userId: {
|
||||
projectId: project1.id,
|
||||
userId: user2.id,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
userId: user2.id,
|
||||
projectId: project1.id,
|
||||
role: "ADMIN",
|
||||
orgMembershipId: orgMembership2.id,
|
||||
},
|
||||
update: {
|
||||
orgMembershipId: orgMembership2.id,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.prompt.upsert({
|
||||
@@ -113,18 +186,39 @@ async function main() {
|
||||
|
||||
// Do not run the following for local docker compose setup
|
||||
if (environment === "examples" || environment === "load") {
|
||||
const project2 = await prisma.project.upsert({
|
||||
where: { id: "239ad00f-562f-411d-af14-831c75ddd875" },
|
||||
const seedOrgIdOrg2 = "demo-org-id";
|
||||
const project2Id = "239ad00f-562f-411d-af14-831c75ddd875";
|
||||
const org2 = await prisma.organization.upsert({
|
||||
where: { id: seedOrgIdOrg2 },
|
||||
update: {
|
||||
name: "Langfuse Demo",
|
||||
},
|
||||
create: {
|
||||
id: "239ad00f-562f-411d-af14-831c75ddd875",
|
||||
id: seedOrgIdOrg2,
|
||||
name: "Langfuse Demo",
|
||||
},
|
||||
});
|
||||
const project2 = await prisma.project.upsert({
|
||||
where: { id: project2Id },
|
||||
create: {
|
||||
id: project2Id,
|
||||
name: "demo-app",
|
||||
projectMembers: {
|
||||
create: {
|
||||
role: "OWNER",
|
||||
userId: user.id,
|
||||
},
|
||||
orgId: org2.id,
|
||||
},
|
||||
update: { orgId: seedOrgIdOrg2 },
|
||||
});
|
||||
await prisma.organizationMembership.upsert({
|
||||
where: {
|
||||
orgId_userId: {
|
||||
userId: user.id,
|
||||
orgId: seedOrgIdOrg2,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
userId: user.id,
|
||||
orgId: seedOrgIdOrg2,
|
||||
role: "VIEWER",
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
@@ -286,6 +380,7 @@ async function main() {
|
||||
: undefined;
|
||||
const datasetItem = await prisma.datasetItem.create({
|
||||
data: {
|
||||
projectId: project2.id,
|
||||
datasetId: dataset.id,
|
||||
sourceTraceId: sourceObservation?.traceId,
|
||||
sourceObservationId:
|
||||
@@ -312,6 +407,7 @@ async function main() {
|
||||
for (let datasetRunNumber = 0; datasetRunNumber < 5; datasetRunNumber++) {
|
||||
const datasetRun = await prisma.datasetRuns.create({
|
||||
data: {
|
||||
projectId: project2.id,
|
||||
name: `demo-dataset-run-${datasetRunNumber}`,
|
||||
description: Math.random() > 0.5 ? "Dataset run description" : "",
|
||||
datasetId: dataset.id,
|
||||
@@ -336,6 +432,7 @@ async function main() {
|
||||
|
||||
await prisma.datasetRunItems.create({
|
||||
data: {
|
||||
projectId: project2.id,
|
||||
datasetItemId,
|
||||
traceId: observation.traceId as string,
|
||||
observationId: Math.random() > 0.5 ? observation.id : undefined,
|
||||
@@ -351,10 +448,14 @@ async function main() {
|
||||
main()
|
||||
.then(async () => {
|
||||
await prisma.$disconnect();
|
||||
redis?.disconnect();
|
||||
console.log("Disconnected from postgres and redis");
|
||||
})
|
||||
.catch(async (e) => {
|
||||
console.error(e);
|
||||
await prisma.$disconnect();
|
||||
redis?.disconnect();
|
||||
console.log("Disconnected from postgres and redis");
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -765,6 +866,7 @@ function createObjects(
|
||||
traceId: trace.id,
|
||||
source: ScoreSource.API,
|
||||
projectId: trace.projectId,
|
||||
timestamp: generationTsEnd,
|
||||
});
|
||||
if (Math.random() > 0.6)
|
||||
scores.push({
|
||||
@@ -774,6 +876,7 @@ function createObjects(
|
||||
traceId: trace.id,
|
||||
source: ScoreSource.API,
|
||||
projectId: trace.projectId,
|
||||
timestamp: generationTsEnd,
|
||||
});
|
||||
|
||||
for (let l = 0; l < Math.floor(Math.random() * 2); l++) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import crypto from "crypto";
|
||||
import { env } from "../env";
|
||||
|
||||
const ENCRYPTION_KEY: string = env.ENCRYPTION_KEY || ""; // Must be 256 bits (32 bytes, 64 hex characters)
|
||||
const ENCRYPTION_KEY: string | undefined = env.ENCRYPTION_KEY; // Must be 256 bits (32 bytes, 64 hex characters)
|
||||
const IV_LENGTH: number = 16; // For AES, this is always 16
|
||||
|
||||
// Alternatively: openssl rand -hex 32
|
||||
@@ -13,17 +13,16 @@ export function keyGen() {
|
||||
* Encrypts the given plain text using AES-256-GCM algorithm.
|
||||
*
|
||||
* @param {string} plainText - The text to encrypt.
|
||||
* @param {string} [keyHex=ENCRYPTION_KEY] - The encryption key in hexadecimal format.
|
||||
* @returns {string} The encrypted data in hex format, including IV and authentication tag.
|
||||
*/
|
||||
export function encrypt(
|
||||
plainText: string,
|
||||
keyHex: string = ENCRYPTION_KEY
|
||||
): string {
|
||||
export function encrypt(plainText: string): string {
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("Missing environment variable: `ENCRYPTION_KEY`");
|
||||
}
|
||||
const iv = crypto.randomBytes(IV_LENGTH); // Directly use Buffer returned by randomBytes
|
||||
const cipher = crypto.createCipheriv(
|
||||
"aes-256-gcm",
|
||||
Buffer.from(keyHex, "hex"),
|
||||
Buffer.from(ENCRYPTION_KEY, "hex"),
|
||||
iv
|
||||
);
|
||||
let encrypted = cipher.update(plainText, "utf8", "hex");
|
||||
@@ -34,7 +33,10 @@ export function encrypt(
|
||||
return iv.toString("hex") + ":" + encrypted + ":" + authTag.toString("hex");
|
||||
}
|
||||
|
||||
export function decrypt(text: string, keyHex: string = ENCRYPTION_KEY): string {
|
||||
export function decrypt(text: string): string {
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("Missing environment variable: `ENCRYPTION_KEY`");
|
||||
}
|
||||
const [ivHex, encryptedHex, authTagHex] = text.split(":");
|
||||
if (!ivHex || !encryptedHex || !authTagHex) {
|
||||
throw new Error("Invalid or corrupted cipher format");
|
||||
@@ -46,7 +48,7 @@ export function decrypt(text: string, keyHex: string = ENCRYPTION_KEY): string {
|
||||
|
||||
const decipher = crypto.createDecipheriv(
|
||||
"aes-256-gcm",
|
||||
Buffer.from(keyHex, "hex"),
|
||||
Buffer.from(ENCRYPTION_KEY, "hex"),
|
||||
iv
|
||||
);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const EnvSchema = z.object({
|
||||
NODE_ENV: z
|
||||
.enum(["development", "test", "production"])
|
||||
.default("development"),
|
||||
REDIS_HOST: z.string().nullish(),
|
||||
REDIS_PORT: z.coerce
|
||||
.number({
|
||||
description:
|
||||
".env files convert numbers to strings, therefoore we have to enforce them to be numbers",
|
||||
})
|
||||
.positive()
|
||||
.max(65536, `options.port should be >= 0 and < 65536`)
|
||||
.default(6379)
|
||||
.nullable(),
|
||||
REDIS_AUTH: z.string().nullish(),
|
||||
REDIS_CONNECTION_STRING: z.string().nullish(),
|
||||
ENCRYPTION_KEY: z
|
||||
.string()
|
||||
.length(
|
||||
@@ -8,6 +23,20 @@ const EnvSchema = z.object({
|
||||
"ENCRYPTION_KEY must be 256 bits, 64 string characters in hex format, generate via: openssl rand -hex 32"
|
||||
)
|
||||
.optional(),
|
||||
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().optional(),
|
||||
CLICKHOUSE_USER: z.string().optional(),
|
||||
CLICKHOUSE_PASSWORD: z.string().optional(),
|
||||
LANGFUSE_INGESTION_FLUSH_DELAY_MS: z.coerce
|
||||
.number()
|
||||
.nonnegative()
|
||||
.default(10000),
|
||||
LANGFUSE_INGESTION_FLUSH_ATTEMPTS: z.coerce.number().positive().default(3),
|
||||
LANGFUSE_INGESTION_BUFFER_TTL_SECONDS: z.coerce
|
||||
.number()
|
||||
.positive()
|
||||
.default(60 * 10),
|
||||
});
|
||||
|
||||
export const env = EnvSchema.parse(process.env);
|
||||
|
||||
@@ -14,7 +14,7 @@ const NumericData = z.object({
|
||||
});
|
||||
|
||||
const CategoricalData = z.object({
|
||||
value: z.number().optional().nullish(),
|
||||
value: z.number().nullish(),
|
||||
stringValue: z.string(),
|
||||
dataType: z.literal("CATEGORICAL"),
|
||||
});
|
||||
@@ -31,7 +31,7 @@ const CreateAnnotationScoreBase = z.object({
|
||||
traceId: z.string(),
|
||||
configId: z.string().optional(),
|
||||
observationId: z.string().optional(),
|
||||
comment: z.string().optional().nullish(),
|
||||
comment: z.string().nullish(),
|
||||
});
|
||||
|
||||
const UpdateAnnotationScoreBase = CreateAnnotationScoreBase.extend({
|
||||
|
||||
@@ -81,6 +81,7 @@ export const evalModelsByAdapter: Record<LLMAdapter, string[]> = {
|
||||
[LLMAdapter.OpenAI]: [
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4o-2024-08-06", // cheaper than gpt-4o
|
||||
"gpt-4-turbo-preview",
|
||||
"gpt-3.5-turbo",
|
||||
] as const,
|
||||
|
||||
+16
-14
@@ -1,12 +1,13 @@
|
||||
import { isPresent } from "@/src/utils/typeChecks";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ScoreConfig as ScoreConfigDbType } from "@prisma/client";
|
||||
|
||||
import { isPresent } from "../../utils/typeChecks";
|
||||
import {
|
||||
jsonSchema,
|
||||
paginationMetaResponseZod,
|
||||
paginationZod,
|
||||
type ScoreConfig as ScoreConfigDbType,
|
||||
} from "@langfuse/shared";
|
||||
import { z } from "zod";
|
||||
import * as Sentry from "@sentry/node";
|
||||
} from "../../utils/zod";
|
||||
|
||||
/**
|
||||
* Types to use across codebase
|
||||
@@ -16,7 +17,7 @@ export type ValidatedScoreConfig = z.infer<typeof ValidatedScoreConfigSchema>;
|
||||
|
||||
const validateCategories = (
|
||||
categories: ConfigCategory[],
|
||||
ctx: z.RefinementCtx,
|
||||
ctx: z.RefinementCtx
|
||||
) => {
|
||||
const uniqueNames = new Set<string>();
|
||||
const uniqueValues = new Set<number>();
|
||||
@@ -53,8 +54,8 @@ export const Category = z.object({
|
||||
const Categories = z.array(Category);
|
||||
|
||||
const NumericScoreConfig = z.object({
|
||||
maxValue: z.number().optional().nullish(),
|
||||
minValue: z.number().optional().nullish(),
|
||||
maxValue: z.number().nullish(),
|
||||
minValue: z.number().nullish(),
|
||||
dataType: z.literal("NUMERIC"),
|
||||
categories: z.undefined().nullish(),
|
||||
});
|
||||
@@ -93,7 +94,7 @@ const BooleanScoreConfig = z.object({
|
||||
return categories.every(
|
||||
(category, index) =>
|
||||
category.label === expectedCategories[index].label &&
|
||||
category.value === expectedCategories[index].value,
|
||||
category.value === expectedCategories[index].value
|
||||
);
|
||||
}),
|
||||
});
|
||||
@@ -102,7 +103,7 @@ const ScoreConfigBase = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1).max(35),
|
||||
isArchived: z.boolean(),
|
||||
description: z.string().optional().nullish(),
|
||||
description: z.string().nullish(),
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date(),
|
||||
projectId: z.string(),
|
||||
@@ -122,7 +123,7 @@ const ValidatedScoreConfigSchema = z
|
||||
minValue: z.undefined().nullish(),
|
||||
dataType: z.literal("CATEGORICAL"),
|
||||
categories: Categories.superRefine(validateCategories),
|
||||
}),
|
||||
})
|
||||
),
|
||||
ScoreConfigBase.merge(BooleanScoreConfig),
|
||||
])
|
||||
@@ -149,13 +150,14 @@ const ValidatedScoreConfigSchema = z
|
||||
*/
|
||||
export const filterAndValidateDbScoreConfigList = (
|
||||
scoreConfigs: ScoreConfigDbType[],
|
||||
onParseError?: (error: z.ZodError) => void
|
||||
): ValidatedScoreConfig[] =>
|
||||
scoreConfigs.reduce((acc, ts) => {
|
||||
const result = ValidatedScoreConfigSchema.safeParse(ts);
|
||||
if (result.success) {
|
||||
acc.push(result.data);
|
||||
} else {
|
||||
Sentry.captureException(result.error);
|
||||
onParseError?.(result.error);
|
||||
}
|
||||
return acc;
|
||||
}, [] as ValidatedScoreConfig[]);
|
||||
@@ -168,7 +170,7 @@ export const filterAndValidateDbScoreConfigList = (
|
||||
* @throws error if score fails validation
|
||||
*/
|
||||
export const validateDbScoreConfig = (
|
||||
scoreConfig: ScoreConfigDbType,
|
||||
scoreConfig: ScoreConfigDbType
|
||||
): ValidatedScoreConfig => ValidatedScoreConfigSchema.parse(scoreConfig);
|
||||
|
||||
/**
|
||||
@@ -203,7 +205,7 @@ export const PostScoreConfigBody = z
|
||||
z.object({
|
||||
dataType: z.literal("BOOLEAN"),
|
||||
categories: z.undefined().nullish(),
|
||||
}),
|
||||
})
|
||||
),
|
||||
])
|
||||
.superRefine((data, ctx) => {
|
||||
+61
-81
@@ -1,19 +1,19 @@
|
||||
import * as Sentry from "@sentry/node";
|
||||
import {
|
||||
paginationZod,
|
||||
paginationMetaResponseZod,
|
||||
NonEmptyString,
|
||||
type Score,
|
||||
stringDateTime,
|
||||
} from "@langfuse/shared";
|
||||
import { z } from "zod";
|
||||
import { isPresent } from "@/src/utils/typeChecks";
|
||||
import { Category as ConfigCategory } from "./score-configs";
|
||||
|
||||
import { Score } from "@prisma/client";
|
||||
|
||||
import { isPresent, stringDateTime } from "../../utils/typeChecks";
|
||||
import {
|
||||
NonEmptyString,
|
||||
paginationMetaResponseZod,
|
||||
paginationZod,
|
||||
} from "../../utils/zod";
|
||||
import { Category as ConfigCategory } from "./scoreConfigTypes";
|
||||
|
||||
/**
|
||||
* Types to use across codebase
|
||||
*/
|
||||
export type APIScore = z.infer<typeof APIScore>;
|
||||
export type APIScore = z.infer<typeof APIScoreSchema>;
|
||||
|
||||
/**
|
||||
* Helpers
|
||||
@@ -31,7 +31,7 @@ const NumericData = z.object({
|
||||
});
|
||||
|
||||
const CategoricalData = z.object({
|
||||
value: z.number().optional().nullish(),
|
||||
value: z.number().nullish(),
|
||||
stringValue: z.string(),
|
||||
dataType: z.literal("CATEGORICAL"),
|
||||
});
|
||||
@@ -69,7 +69,7 @@ const BaseScoreBody = z.object({
|
||||
* Objects
|
||||
*/
|
||||
|
||||
export const APIScore = z.discriminatedUnion("dataType", [
|
||||
export const APIScoreSchema = z.discriminatedUnion("dataType", [
|
||||
ScoreBase.merge(NumericData),
|
||||
ScoreBase.merge(CategoricalData),
|
||||
ScoreBase.merge(BooleanData),
|
||||
@@ -83,13 +83,13 @@ export const ScoreBodyWithoutConfig = z.discriminatedUnion("dataType", [
|
||||
z.object({
|
||||
value: z.number(),
|
||||
dataType: z.literal("NUMERIC"),
|
||||
}),
|
||||
})
|
||||
),
|
||||
BaseScoreBody.merge(
|
||||
z.object({
|
||||
value: z.string(),
|
||||
dataType: z.literal("CATEGORICAL"),
|
||||
}),
|
||||
})
|
||||
),
|
||||
BaseScoreBody.merge(
|
||||
z.object({
|
||||
@@ -97,7 +97,7 @@ export const ScoreBodyWithoutConfig = z.discriminatedUnion("dataType", [
|
||||
message: "Value must be either 0 or 1",
|
||||
}),
|
||||
dataType: z.literal("BOOLEAN"),
|
||||
}),
|
||||
})
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -159,14 +159,17 @@ export const ScorePropsAgainstConfig = z.union([
|
||||
* @param scores
|
||||
* @returns list of validated scores
|
||||
*/
|
||||
export const filterAndValidateDbScoreList = (scores: Score[]): APIScore[] =>
|
||||
export const filterAndValidateDbScoreList = (
|
||||
scores: Score[],
|
||||
onParseError?: (error: z.ZodError) => void
|
||||
): APIScore[] =>
|
||||
scores.reduce((acc, ts) => {
|
||||
const result = APIScore.safeParse(ts);
|
||||
const result = APIScoreSchema.safeParse(ts);
|
||||
if (result.success) {
|
||||
acc.push(result.data);
|
||||
} else {
|
||||
console.error("Score parsing error: ", result.error);
|
||||
Sentry.captureException(result.error);
|
||||
onParseError?.(result.error);
|
||||
}
|
||||
return acc;
|
||||
}, [] as APIScore[]);
|
||||
@@ -179,7 +182,7 @@ export const filterAndValidateDbScoreList = (scores: Score[]): APIScore[] =>
|
||||
* @throws error if score fails validation
|
||||
*/
|
||||
export const validateDbScore = (score: Score): APIScore =>
|
||||
APIScore.parse(score);
|
||||
APIScoreSchema.parse(score);
|
||||
|
||||
/**
|
||||
* Endpoints
|
||||
@@ -189,63 +192,39 @@ export const validateDbScore = (score: Score): APIScore =>
|
||||
/**
|
||||
* PostScoresBody is copied for the ingestion API as `ScoreBody`. Please copy any changes here in `packages/shared/src/features/ingestion/types.ts`
|
||||
*/
|
||||
export const PostScoresBody = z
|
||||
.discriminatedUnion("dataType", [
|
||||
BaseScoreBody.merge(
|
||||
z.object({
|
||||
value: z.number(),
|
||||
dataType: z.literal("NUMERIC"),
|
||||
configId: z.string().nullish(),
|
||||
export const PostScoresBody = z.discriminatedUnion("dataType", [
|
||||
BaseScoreBody.merge(
|
||||
z.object({
|
||||
value: z.number(),
|
||||
dataType: z.literal("NUMERIC"),
|
||||
configId: z.string().nullish(),
|
||||
})
|
||||
),
|
||||
BaseScoreBody.merge(
|
||||
z.object({
|
||||
value: z.string(),
|
||||
dataType: z.literal("CATEGORICAL"),
|
||||
configId: z.string().nullish(),
|
||||
})
|
||||
),
|
||||
BaseScoreBody.merge(
|
||||
z.object({
|
||||
value: z.number().refine((value) => value === 0 || value === 1, {
|
||||
message:
|
||||
"Value must be a number equal to either 0 or 1 for data type BOOLEAN",
|
||||
}),
|
||||
),
|
||||
BaseScoreBody.merge(
|
||||
z.object({
|
||||
value: z.string(),
|
||||
dataType: z.literal("CATEGORICAL"),
|
||||
configId: z.string().nullish(),
|
||||
}),
|
||||
),
|
||||
BaseScoreBody.merge(
|
||||
z.object({
|
||||
value: z.number().refine((val) => val === 0 || val === 1, {
|
||||
message: "Value must be either 0 or 1",
|
||||
}),
|
||||
dataType: z.literal("BOOLEAN"),
|
||||
configId: z.string().nullish(),
|
||||
}),
|
||||
),
|
||||
BaseScoreBody.merge(
|
||||
z.object({
|
||||
value: z.union([z.string(), z.number()]),
|
||||
dataType: z.undefined(),
|
||||
configId: z.string().nullish(),
|
||||
}),
|
||||
),
|
||||
])
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.dataType) {
|
||||
if (typeof data.value === "number") {
|
||||
if (data.dataType === "CATEGORICAL") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Value must be a string for data type ${data.dataType}`,
|
||||
});
|
||||
}
|
||||
} else if (typeof data.value === "string") {
|
||||
if (data.dataType === "NUMERIC") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Value must be a number for data type ${data.dataType}`,
|
||||
});
|
||||
} else if (data.dataType === "BOOLEAN") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Value must number equal to either 0 or 1 for data type ${data.dataType}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
dataType: z.literal("BOOLEAN"),
|
||||
configId: z.string().nullish(),
|
||||
})
|
||||
),
|
||||
BaseScoreBody.merge(
|
||||
z.object({
|
||||
value: z.union([z.string(), z.number()]),
|
||||
dataType: z.undefined(),
|
||||
configId: z.string().nullish(),
|
||||
})
|
||||
),
|
||||
]);
|
||||
|
||||
export const PostScoresResponse = z.object({ id: z.string() });
|
||||
|
||||
@@ -272,12 +251,12 @@ export const GetScoresQuery = z.object({
|
||||
|
||||
// LegacyGetScoreResponseDataV1 is only used for response of GET /scores list endpoint
|
||||
const LegacyGetScoreResponseDataV1 = z.intersection(
|
||||
APIScore,
|
||||
APIScoreSchema,
|
||||
z.object({
|
||||
trace: z.object({
|
||||
userId: z.string().nullish(),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
export const GetScoresResponse = z.object({
|
||||
data: z.array(LegacyGetScoreResponseDataV1),
|
||||
@@ -286,6 +265,7 @@ export const GetScoresResponse = z.object({
|
||||
|
||||
export const legacyFilterAndValidateV1GetScoreList = (
|
||||
scores: unknown[],
|
||||
onParseError?: (error: z.ZodError) => void
|
||||
): z.infer<typeof LegacyGetScoreResponseDataV1>[] =>
|
||||
scores.reduce(
|
||||
(acc: z.infer<typeof LegacyGetScoreResponseDataV1>[], ts) => {
|
||||
@@ -294,11 +274,11 @@ export const legacyFilterAndValidateV1GetScoreList = (
|
||||
acc.push(result.data);
|
||||
} else {
|
||||
console.error("Score parsing error: ", result.error);
|
||||
Sentry.captureException(result.error);
|
||||
onParseError?.(result.error);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[] as z.infer<typeof LegacyGetScoreResponseDataV1>[],
|
||||
[] as z.infer<typeof LegacyGetScoreResponseDataV1>[]
|
||||
);
|
||||
|
||||
// GET /scores/{scoreId}
|
||||
@@ -306,7 +286,7 @@ export const GetScoreQuery = z.object({
|
||||
scoreId: z.string(),
|
||||
});
|
||||
|
||||
export const GetScoreResponse = APIScore;
|
||||
export const GetScoreResponse = APIScoreSchema;
|
||||
|
||||
// DELETE /scores/{scoreId}
|
||||
export const DeleteScoreQuery = z.object({
|
||||
@@ -1,6 +1,5 @@
|
||||
export * from "./constants";
|
||||
export * from "./queries";
|
||||
export * from "./queues";
|
||||
export * from "./interfaces/filters";
|
||||
export * from "./interfaces/orderBy";
|
||||
export * from "./tableDefinitions";
|
||||
@@ -8,11 +7,12 @@ export * from "./types";
|
||||
export * from "./filterToPrisma";
|
||||
export * from "./orderByToPrisma";
|
||||
export * from "./tracesTable";
|
||||
export * from "./server/auth";
|
||||
export * from "./server/auth/auth";
|
||||
export * from "./observationsTable";
|
||||
export * from "./features/ingestion/types";
|
||||
export * from "./utils/zod";
|
||||
export * from "./utils/json";
|
||||
export * from "./utils/objects";
|
||||
export * from "./utils/typeChecks";
|
||||
export { env } from "./env";
|
||||
|
||||
// llm api
|
||||
@@ -26,6 +26,10 @@ export * from "./features/batchExport/types";
|
||||
// annotation
|
||||
export * from "./features/annotation/types";
|
||||
|
||||
// scores
|
||||
export * from "./features/scores/scoreConfigTypes";
|
||||
export * from "./features/scores/scoreTypes";
|
||||
|
||||
// export db types only
|
||||
export * from "@prisma/client";
|
||||
export { type DB } from "../prisma/generated/types";
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export type AuthHeaderVerificationResult =
|
||||
| AuthHeaderValidVerificationResult
|
||||
| {
|
||||
validKey: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export type AuthHeaderValidVerificationResult = {
|
||||
validKey: true;
|
||||
scope: ApiAccessScope;
|
||||
};
|
||||
|
||||
export type ApiAccessScope = {
|
||||
projectId: string;
|
||||
accessLevel: "all" | "scores";
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createClient } from "@clickhouse/client";
|
||||
|
||||
import { env } from "../env";
|
||||
|
||||
export type ClickhouseClientType = ReturnType<typeof createClient>;
|
||||
|
||||
export const clickhouseClient = createClient({
|
||||
url: env.CLICKHOUSE_URL,
|
||||
username: env.CLICKHOUSE_USER,
|
||||
password: env.CLICKHOUSE_PASSWORD,
|
||||
database: "default",
|
||||
clickhouse_settings: {
|
||||
async_insert: 1,
|
||||
wait_for_async_insert: 1, // if disabled, we won't get errors from clickhouse
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import z from "zod";
|
||||
|
||||
export const clickhouseStringDateSchema = z
|
||||
.string()
|
||||
// clickhouse stores UTC like '2024-05-23 18:33:41.602000'
|
||||
// we need to convert it to '2024-05-23T18:33:41.602000Z'
|
||||
.transform((str) => str.replace(" ", "T") + "Z")
|
||||
.pipe(z.string().datetime());
|
||||
|
||||
export const observationRecordBaseSchema = z.object({
|
||||
id: z.string(),
|
||||
trace_id: z.string().nullish(),
|
||||
project_id: z.string(),
|
||||
type: z.string(),
|
||||
parent_observation_id: z.string().nullish(),
|
||||
name: z.string().nullish(),
|
||||
metadata: z.record(z.string()),
|
||||
level: z.string().nullish(),
|
||||
status_message: z.string().nullish(),
|
||||
version: z.string().nullish(),
|
||||
input: z.string().nullish(),
|
||||
output: z.string().nullish(),
|
||||
provided_model_name: z.string().nullish(),
|
||||
internal_model_id: z.string().nullish(),
|
||||
model_parameters: z.string().nullish(),
|
||||
unit: z.string().nullish(),
|
||||
input_usage_units: z.number().nullish(),
|
||||
output_usage_units: z.number().nullish(),
|
||||
total_usage_units: z.number().nullish(),
|
||||
input_cost: z.number().nullish(),
|
||||
output_cost: z.number().nullish(),
|
||||
total_cost: z.number().nullish(),
|
||||
provided_input_usage_units: z.number().nullish(),
|
||||
provided_output_usage_units: z.number().nullish(),
|
||||
provided_total_usage_units: z.number().nullish(),
|
||||
provided_input_cost: z.number().nullish(),
|
||||
provided_output_cost: z.number().nullish(),
|
||||
provided_total_cost: z.number().nullish(),
|
||||
prompt_id: z.string().nullish(),
|
||||
prompt_name: z.string().nullish(),
|
||||
prompt_version: z.number().nullish(),
|
||||
});
|
||||
export type ObservationRecordBaseType = z.infer<
|
||||
typeof observationRecordBaseSchema
|
||||
>;
|
||||
|
||||
export const observationRecordReadSchema = observationRecordBaseSchema.extend({
|
||||
created_at: clickhouseStringDateSchema,
|
||||
updated_at: clickhouseStringDateSchema,
|
||||
start_time: clickhouseStringDateSchema,
|
||||
end_time: clickhouseStringDateSchema.nullish(),
|
||||
completion_start_time: clickhouseStringDateSchema.nullish(),
|
||||
});
|
||||
export type ObservationRecordReadType = z.infer<
|
||||
typeof observationRecordReadSchema
|
||||
>;
|
||||
|
||||
export const observationRecordInsertSchema = observationRecordBaseSchema.extend(
|
||||
{
|
||||
created_at: z.number(),
|
||||
updated_at: z.number(),
|
||||
start_time: z.number(),
|
||||
end_time: z.number().nullish(),
|
||||
completion_start_time: z.number().nullish(),
|
||||
}
|
||||
);
|
||||
export type ObservationRecordInsertType = z.infer<
|
||||
typeof observationRecordInsertSchema
|
||||
>;
|
||||
|
||||
export const traceRecordBaseSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().nullish(),
|
||||
user_id: z.string().nullish(),
|
||||
metadata: z.record(z.string()),
|
||||
release: z.string().nullish(),
|
||||
version: z.string().nullish(),
|
||||
project_id: z.string(),
|
||||
public: z.boolean(),
|
||||
bookmarked: z.boolean(),
|
||||
tags: z.array(z.string()),
|
||||
input: z.string().nullish(),
|
||||
output: z.string().nullish(),
|
||||
session_id: z.string().nullish(),
|
||||
});
|
||||
export type TraceRecordBaseType = z.infer<typeof traceRecordBaseSchema>;
|
||||
|
||||
export const traceRecordReadSchema = traceRecordBaseSchema.extend({
|
||||
timestamp: clickhouseStringDateSchema,
|
||||
created_at: clickhouseStringDateSchema,
|
||||
updated_at: clickhouseStringDateSchema,
|
||||
});
|
||||
export type TraceRecordReadType = z.infer<typeof traceRecordReadSchema>;
|
||||
|
||||
export const traceRecordInsertSchema = traceRecordBaseSchema.extend({
|
||||
timestamp: z.number(),
|
||||
created_at: z.number(),
|
||||
updated_at: z.number(),
|
||||
});
|
||||
export type TraceRecordInsertType = z.infer<typeof traceRecordInsertSchema>;
|
||||
|
||||
export const scoreRecordBaseSchema = z.object({
|
||||
id: z.string(),
|
||||
project_id: z.string(),
|
||||
trace_id: z.string(),
|
||||
observation_id: z.string().nullish(),
|
||||
name: z.string().nullish(),
|
||||
value: z.union([z.number(), z.string()]).nullish(),
|
||||
source: z.string(),
|
||||
comment: z.string().nullish(),
|
||||
author_user_id: z.string().nullish(),
|
||||
config_id: z.string().nullish(),
|
||||
data_type: z.enum(["NUMERIC", "CATEGORICAL", "BOOLEAN"]).nullish(),
|
||||
string_value: z.string().nullish(),
|
||||
});
|
||||
export type ScoreRecordBaseType = z.infer<typeof scoreRecordBaseSchema>;
|
||||
|
||||
export const scoreRecordReadSchema = scoreRecordBaseSchema.extend({
|
||||
created_at: clickhouseStringDateSchema,
|
||||
updated_at: clickhouseStringDateSchema,
|
||||
timestamp: clickhouseStringDateSchema,
|
||||
});
|
||||
export type ScoreRecordReadType = z.infer<typeof scoreRecordReadSchema>;
|
||||
|
||||
export const scoreRecordInsertSchema = scoreRecordBaseSchema.extend({
|
||||
created_at: z.number(),
|
||||
updated_at: z.number(),
|
||||
timestamp: z.number(),
|
||||
});
|
||||
export type ScoreRecordInsertType = z.infer<typeof scoreRecordInsertSchema>;
|
||||
|
||||
export const convertTraceReadToInsert = (
|
||||
record: TraceRecordReadType
|
||||
): TraceRecordInsertType => {
|
||||
return {
|
||||
...record,
|
||||
created_at: new Date(record.created_at).getTime(),
|
||||
updated_at: new Date(record.created_at).getTime(),
|
||||
timestamp: new Date(record.timestamp).getTime(),
|
||||
};
|
||||
};
|
||||
|
||||
export const convertObservationReadToInsert = (
|
||||
record: ObservationRecordReadType
|
||||
): ObservationRecordInsertType => {
|
||||
return {
|
||||
...record,
|
||||
created_at: new Date(record.created_at).getTime(),
|
||||
updated_at: new Date(record.created_at).getTime(),
|
||||
start_time: new Date(record.start_time).getTime(),
|
||||
end_time: record.end_time ? new Date(record.end_time).getTime() : undefined,
|
||||
completion_start_time: record.completion_start_time
|
||||
? new Date(record.completion_start_time).getTime()
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const convertScoreReadToInsert = (
|
||||
record: ScoreRecordReadType
|
||||
): ScoreRecordInsertType => {
|
||||
return {
|
||||
...record,
|
||||
created_at: new Date(record.created_at).getTime(),
|
||||
updated_at: new Date(record.updated_at).getTime(),
|
||||
timestamp: new Date(record.timestamp).getTime(),
|
||||
};
|
||||
};
|
||||
@@ -1,9 +1,26 @@
|
||||
export * from "./services/S3StorageService";
|
||||
export * from "./services/email/projectInvitation/sendProjectInvitationEmail";
|
||||
export * from "./services/email/organizationInvitation/sendMembershipInvitationEmail";
|
||||
export * from "./services/email/batchExportSuccess/sendBatchExportSuccessEmail";
|
||||
export * from "./services/email/passwordReset/sendResetPasswordVerificationRequest";
|
||||
export * from "./auth";
|
||||
export * from "./services/PromptService";
|
||||
export * from "./auth/auth";
|
||||
export * from "./llm/fetchLLMCompletion";
|
||||
export * from "./llm/types";
|
||||
export * from "./utils/DatabaseReadStream";
|
||||
export * from "./utils/transforms";
|
||||
export * from "./clickhouse";
|
||||
export * from "../server/definitions";
|
||||
export * from "../server/ingestion/IngestionUtils";
|
||||
export * from "../server/ingestion/types";
|
||||
export * from "../server/ingestion/model-match";
|
||||
export * from "../server/ingestion/types";
|
||||
export * from "../server/ingestion/validateAndInflateScore";
|
||||
export * from "./redis/redis";
|
||||
export * from "./redis/trace-upsert";
|
||||
export * from "./redis/batch-export";
|
||||
export * from "./redis/ingestionFlushQueue";
|
||||
export * from "./redis/legacy-ingestion";
|
||||
export * from "./auth/types";
|
||||
export * from "./ingestion/legacy/index";
|
||||
export * from "./queues";
|
||||
export * from "./ingestion/legacy/EventProcessor";
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { eventTypes, IngestionEventType } from "./types";
|
||||
|
||||
const reservedCharsEscapeMap = [
|
||||
{ reserved: ":", escape: "|%|" },
|
||||
{ reserved: "_", escape: "|#|" },
|
||||
];
|
||||
|
||||
export enum ClickhouseEntityType {
|
||||
Trace = "trace",
|
||||
Score = "score",
|
||||
Observation = "observation",
|
||||
SDK_LOG = "sdk-log",
|
||||
}
|
||||
|
||||
export class IngestionUtils {
|
||||
public static getBufferKey(flushKey: string): string {
|
||||
const { projectId, eventType, entityId } =
|
||||
IngestionUtils.parseFlushKey(flushKey);
|
||||
|
||||
const sanitizedEntityId = IngestionUtils.escapeReservedChars(entityId);
|
||||
|
||||
return (
|
||||
"ingestionBuffer:" + `${projectId}_${eventType}_${sanitizedEntityId}`
|
||||
);
|
||||
}
|
||||
|
||||
public static getFlushKey(params: {
|
||||
projectId: string;
|
||||
eventType: ClickhouseEntityType;
|
||||
entityId: string;
|
||||
batchTimestamp: string;
|
||||
}): string {
|
||||
const { projectId, eventType, entityId, batchTimestamp } = params;
|
||||
const sanitizedEntityId = IngestionUtils.escapeReservedChars(entityId);
|
||||
|
||||
return `${projectId}_${eventType}_${sanitizedEntityId}_${batchTimestamp}`;
|
||||
}
|
||||
|
||||
public static parseFlushKey(projectEntityKey: string) {
|
||||
const split = projectEntityKey.split("_");
|
||||
|
||||
if (split.length < 3) {
|
||||
throw new Error(
|
||||
`Invalid project entity key format ${projectEntityKey}, expected 3 or 4 parts`
|
||||
);
|
||||
}
|
||||
|
||||
const [projectId, eventType, escapedEntityId, batchTimestamp] = split;
|
||||
const entityId = IngestionUtils.unescapeReservedChars(escapedEntityId);
|
||||
|
||||
return { projectId, eventType, entityId, batchTimestamp };
|
||||
}
|
||||
|
||||
private static escapeReservedChars(string: string): string {
|
||||
return reservedCharsEscapeMap.reduce(
|
||||
(acc, { reserved, escape }) => acc.replaceAll(reserved, escape),
|
||||
string
|
||||
);
|
||||
}
|
||||
|
||||
private static unescapeReservedChars(escapedString: string): string {
|
||||
return reservedCharsEscapeMap.reduce(
|
||||
(acc, { reserved, escape }) => acc.replaceAll(escape, reserved),
|
||||
escapedString
|
||||
);
|
||||
}
|
||||
|
||||
public static getEventType(event: IngestionEventType): ClickhouseEntityType {
|
||||
switch (event.type) {
|
||||
case eventTypes.TRACE_CREATE:
|
||||
return ClickhouseEntityType.Trace;
|
||||
case eventTypes.OBSERVATION_CREATE:
|
||||
case eventTypes.OBSERVATION_UPDATE:
|
||||
case eventTypes.EVENT_CREATE:
|
||||
case eventTypes.SPAN_CREATE:
|
||||
case eventTypes.SPAN_UPDATE:
|
||||
case eventTypes.GENERATION_CREATE:
|
||||
case eventTypes.GENERATION_UPDATE:
|
||||
return ClickhouseEntityType.Observation;
|
||||
case eventTypes.SCORE_CREATE:
|
||||
return ClickhouseEntityType.Score;
|
||||
case eventTypes.SDK_LOG:
|
||||
return ClickhouseEntityType.SDK_LOG;
|
||||
}
|
||||
}
|
||||
}
|
||||
+96
-296
@@ -1,131 +1,53 @@
|
||||
import { tokenCount } from "@/src/features/ingest/lib/usage";
|
||||
import { type ApiAccessScope } from "@/src/features/public-api/server/types";
|
||||
import {
|
||||
type legacyObservationCreateEvent,
|
||||
eventTypes,
|
||||
type scoreEvent,
|
||||
type eventCreateEvent,
|
||||
type spanCreateEvent,
|
||||
type generationCreateEvent,
|
||||
type spanUpdateEvent,
|
||||
type generationUpdateEvent,
|
||||
type legacyObservationUpdateEvent,
|
||||
type sdkLogEvent,
|
||||
type traceEvent,
|
||||
LangfuseNotFoundError,
|
||||
InvalidRequestError,
|
||||
} from "@langfuse/shared";
|
||||
import { ScoreDataType, prisma } from "@langfuse/shared/src/db";
|
||||
import { ResourceNotFoundError } from "@/src/utils/exceptions";
|
||||
import { mergeJson } from "@langfuse/shared";
|
||||
import {
|
||||
type Trace,
|
||||
type Observation,
|
||||
type Score,
|
||||
Prisma,
|
||||
type Model,
|
||||
} from "@langfuse/shared/src/db";
|
||||
import { v4 } from "uuid";
|
||||
import { type z } from "zod";
|
||||
import { jsonSchema } from "@langfuse/shared";
|
||||
import { ForbiddenError } from "@langfuse/shared";
|
||||
import { instrument } from "@/src/utils/instrumentation";
|
||||
import Decimal from "decimal.js";
|
||||
import { findModel } from "../model-match";
|
||||
import {
|
||||
ScoreBodyWithoutConfig,
|
||||
ScorePropsAgainstConfig,
|
||||
} from "@/src/features/public-api/types/scores";
|
||||
import {
|
||||
validateDbScoreConfigSafe,
|
||||
type ValidatedScoreConfig,
|
||||
} from "@/src/features/public-api/types/score-configs";
|
||||
ObservationEvent,
|
||||
eventTypes,
|
||||
legacyObservationCreateEvent,
|
||||
generationCreateEvent,
|
||||
traceEvent,
|
||||
scoreEvent,
|
||||
sdkLogEvent,
|
||||
} from "../types";
|
||||
import { validateAndInflateScore } from "../validateAndInflateScore";
|
||||
import { ApiAccessScope } from "../../auth/types";
|
||||
import { Trace, Observation, Score, Prisma, Model } from "@prisma/client";
|
||||
import { ForbiddenError, LangfuseNotFoundError } from "../../../errors";
|
||||
import { mergeJson } from "../../../utils/json";
|
||||
import { jsonSchema } from "../../../utils/zod";
|
||||
import { prisma } from "../../../db";
|
||||
|
||||
export interface EventProcessor {
|
||||
auth(apiScope: ApiAccessScope): void;
|
||||
|
||||
process(
|
||||
apiScope: ApiAccessScope,
|
||||
apiScope: ApiAccessScope
|
||||
): Promise<Trace | Observation | Score> | undefined;
|
||||
}
|
||||
|
||||
export async function findModel(p: {
|
||||
event: {
|
||||
projectId: string;
|
||||
model?: string;
|
||||
unit?: string;
|
||||
startTime?: Date;
|
||||
};
|
||||
existingDbObservation?: Observation;
|
||||
}): Promise<Model | null> {
|
||||
const { event, existingDbObservation } = p;
|
||||
// either get the model from the existing observation
|
||||
// or match pattern on the user provided model name
|
||||
const modelCondition = event.model
|
||||
? Prisma.sql`AND ${event.model} ~ match_pattern`
|
||||
: existingDbObservation?.internalModel
|
||||
? Prisma.sql`AND model_name = ${existingDbObservation.internalModel}`
|
||||
: undefined;
|
||||
if (!modelCondition) return null;
|
||||
|
||||
// unit based on the current event or the existing observation, both can be undefined
|
||||
const mergedUnit = event.unit ?? existingDbObservation?.unit;
|
||||
|
||||
const unitCondition = mergedUnit
|
||||
? Prisma.sql`AND unit = ${mergedUnit}`
|
||||
: Prisma.empty;
|
||||
|
||||
const sql = Prisma.sql`
|
||||
SELECT
|
||||
id,
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
project_id AS "projectId",
|
||||
model_name AS "modelName",
|
||||
match_pattern AS "matchPattern",
|
||||
start_date AS "startDate",
|
||||
input_price AS "inputPrice",
|
||||
output_price AS "outputPrice",
|
||||
total_price AS "totalPrice",
|
||||
unit,
|
||||
tokenizer_id AS "tokenizerId",
|
||||
tokenizer_config AS "tokenizerConfig"
|
||||
FROM
|
||||
models
|
||||
WHERE (project_id = ${event.projectId}
|
||||
OR project_id IS NULL)
|
||||
${modelCondition}
|
||||
${unitCondition}
|
||||
AND (start_date IS NULL OR start_date <= ${
|
||||
event.startTime ? new Date(event.startTime) : new Date()
|
||||
}::timestamp with time zone at time zone 'UTC')
|
||||
ORDER BY
|
||||
project_id ASC,
|
||||
start_date DESC NULLS LAST
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const foundModels = await prisma.$queryRaw<Array<Model>>(sql);
|
||||
|
||||
return foundModels[0] ?? null;
|
||||
}
|
||||
|
||||
type ObservationEvent =
|
||||
| z.infer<typeof legacyObservationCreateEvent>
|
||||
| z.infer<typeof legacyObservationUpdateEvent>
|
||||
| z.infer<typeof eventCreateEvent>
|
||||
| z.infer<typeof spanCreateEvent>
|
||||
| z.infer<typeof spanUpdateEvent>
|
||||
| z.infer<typeof generationCreateEvent>
|
||||
| z.infer<typeof generationUpdateEvent>;
|
||||
|
||||
export class ObservationProcessor implements EventProcessor {
|
||||
event: ObservationEvent;
|
||||
calculateTokenDelegate: (p: {
|
||||
model: Model;
|
||||
text: unknown;
|
||||
}) => number | undefined;
|
||||
|
||||
constructor(event: ObservationEvent) {
|
||||
constructor(
|
||||
event: ObservationEvent,
|
||||
calculateTokenDelegate: (p: {
|
||||
model: Model;
|
||||
text: unknown;
|
||||
}) => number | undefined
|
||||
) {
|
||||
this.event = event;
|
||||
this.calculateTokenDelegate = calculateTokenDelegate;
|
||||
}
|
||||
|
||||
async convertToObservation(
|
||||
apiScope: ApiAccessScope,
|
||||
existingObservation: Observation | null,
|
||||
existingObservation: Observation | null
|
||||
): Promise<{
|
||||
id: string;
|
||||
create: Prisma.ObservationUncheckedCreateInput;
|
||||
@@ -154,7 +76,9 @@ export class ObservationProcessor implements EventProcessor {
|
||||
this.event.type === eventTypes.OBSERVATION_UPDATE &&
|
||||
!existingObservation
|
||||
) {
|
||||
throw new ResourceNotFoundError(this.event.id, "Observation not found");
|
||||
throw new LangfuseNotFoundError(
|
||||
`Observation with id ${this.event.id} not found`
|
||||
);
|
||||
}
|
||||
|
||||
// find matching model definition based on event and existing observation in db
|
||||
@@ -197,8 +121,9 @@ export class ObservationProcessor implements EventProcessor {
|
||||
"usage" in this.event.body
|
||||
? this.calculateTokenCounts(
|
||||
this.event.body,
|
||||
this.calculateTokenDelegate,
|
||||
internalModel ?? undefined,
|
||||
existingObservation ?? undefined,
|
||||
existingObservation ?? undefined
|
||||
)
|
||||
: [undefined, undefined];
|
||||
|
||||
@@ -234,7 +159,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
const calculatedCosts = ObservationProcessor.calculateTokenCosts(
|
||||
internalModel,
|
||||
userProvidedTokenCosts,
|
||||
tokenCounts,
|
||||
tokenCounts
|
||||
);
|
||||
|
||||
// merge metadata from existingObservation.metadata and metadata
|
||||
@@ -242,7 +167,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
existingObservation?.metadata
|
||||
? jsonSchema.parse(existingObservation.metadata)
|
||||
: undefined,
|
||||
this.event.body.metadata ?? undefined,
|
||||
this.event.body.metadata ?? undefined
|
||||
);
|
||||
|
||||
const prompt =
|
||||
@@ -388,34 +313,34 @@ export class ObservationProcessor implements EventProcessor {
|
||||
body:
|
||||
| z.infer<typeof legacyObservationCreateEvent>["body"]
|
||||
| z.infer<typeof generationCreateEvent>["body"],
|
||||
calculateTokenDelegate: (p: {
|
||||
model: Model;
|
||||
text: unknown;
|
||||
}) => number | undefined,
|
||||
model?: Model,
|
||||
existingObservation?: Observation,
|
||||
existingObservation?: Observation
|
||||
) {
|
||||
return instrument({ name: "calculate-tokens" }, () => {
|
||||
const newPromptTokens =
|
||||
body.usage?.input ??
|
||||
((body.input || existingObservation?.input) &&
|
||||
model &&
|
||||
model.tokenizerId
|
||||
? tokenCount({
|
||||
model: model,
|
||||
text: body.input ?? existingObservation?.input,
|
||||
})
|
||||
: undefined);
|
||||
const newPromptTokens =
|
||||
body.usage?.input ??
|
||||
((body.input || existingObservation?.input) && model && model.tokenizerId
|
||||
? calculateTokenDelegate({
|
||||
model: model,
|
||||
text: body.input ?? existingObservation?.input,
|
||||
})
|
||||
: undefined);
|
||||
|
||||
const newCompletionTokens =
|
||||
body.usage?.output ??
|
||||
((body.output || existingObservation?.output) &&
|
||||
model &&
|
||||
model.tokenizerId
|
||||
? tokenCount({
|
||||
model: model,
|
||||
text: body.output ?? existingObservation?.output,
|
||||
})
|
||||
: undefined);
|
||||
const newCompletionTokens =
|
||||
body.usage?.output ??
|
||||
((body.output || existingObservation?.output) &&
|
||||
model &&
|
||||
model.tokenizerId
|
||||
? calculateTokenDelegate({
|
||||
model: model,
|
||||
text: body.output ?? existingObservation?.output,
|
||||
})
|
||||
: undefined);
|
||||
|
||||
return [newPromptTokens, newCompletionTokens];
|
||||
});
|
||||
return [newPromptTokens, newCompletionTokens];
|
||||
}
|
||||
|
||||
static calculateTokenCosts(
|
||||
@@ -425,7 +350,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
outputCost?: Decimal | null;
|
||||
totalCost?: Decimal | null;
|
||||
},
|
||||
tokenCounts: { input?: number; output?: number; total?: number },
|
||||
tokenCounts: { input?: number; output?: number; total?: number }
|
||||
): {
|
||||
inputCost?: Decimal | null;
|
||||
outputCost?: Decimal | null;
|
||||
@@ -442,7 +367,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
totalCost:
|
||||
userProvidedCosts.totalCost ??
|
||||
(userProvidedCosts.inputCost ?? new Decimal(0)).add(
|
||||
userProvidedCosts.outputCost ?? new Decimal(0),
|
||||
userProvidedCosts.outputCost ?? new Decimal(0)
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -473,9 +398,13 @@ export class ObservationProcessor implements EventProcessor {
|
||||
};
|
||||
}
|
||||
|
||||
async process(apiScope: ApiAccessScope): Promise<Observation> {
|
||||
auth(apiScope: ApiAccessScope): void {
|
||||
if (apiScope.accessLevel !== "all")
|
||||
throw new ForbiddenError("Access denied for observation creation");
|
||||
}
|
||||
|
||||
async process(apiScope: ApiAccessScope): Promise<Observation> {
|
||||
this.auth(apiScope);
|
||||
|
||||
const existingObservation = this.event.body.id
|
||||
? await prisma.observation.findFirst({
|
||||
@@ -488,7 +417,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
existingObservation.projectId !== apiScope.projectId
|
||||
) {
|
||||
throw new ForbiddenError(
|
||||
`Access denied for observation creation ${existingObservation.projectId} `,
|
||||
`Access denied for observation creation ${existingObservation.projectId} `
|
||||
);
|
||||
}
|
||||
|
||||
@@ -512,15 +441,17 @@ export class TraceProcessor implements EventProcessor {
|
||||
this.event = event;
|
||||
}
|
||||
|
||||
auth(apiScope: ApiAccessScope): void {
|
||||
if (apiScope.accessLevel !== "all")
|
||||
throw new ForbiddenError("Access denied for trace creation");
|
||||
}
|
||||
|
||||
async process(
|
||||
apiScope: ApiAccessScope,
|
||||
apiScope: ApiAccessScope
|
||||
): Promise<Trace | Observation | Score> {
|
||||
const { body } = this.event;
|
||||
|
||||
if (apiScope.accessLevel !== "all")
|
||||
throw new ForbiddenError(
|
||||
`Access denied for trace creation, ${apiScope.accessLevel}`,
|
||||
);
|
||||
this.auth(apiScope);
|
||||
|
||||
const internalId = body.id ?? v4();
|
||||
|
||||
@@ -528,7 +459,7 @@ export class TraceProcessor implements EventProcessor {
|
||||
"Trying to create trace, project ",
|
||||
apiScope.projectId,
|
||||
", id:",
|
||||
internalId,
|
||||
internalId
|
||||
);
|
||||
|
||||
const existingTrace = await prisma.trace.findFirst({
|
||||
@@ -539,7 +470,7 @@ export class TraceProcessor implements EventProcessor {
|
||||
|
||||
if (existingTrace && existingTrace.projectId !== apiScope.projectId) {
|
||||
throw new ForbiddenError(
|
||||
`Access denied for trace creation ${existingTrace.projectId}`,
|
||||
`Access denied for trace creation ${existingTrace.projectId}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -547,7 +478,7 @@ export class TraceProcessor implements EventProcessor {
|
||||
existingTrace?.metadata
|
||||
? jsonSchema.parse(existingTrace.metadata)
|
||||
: undefined,
|
||||
body.metadata ?? undefined,
|
||||
body.metadata ?? undefined
|
||||
);
|
||||
|
||||
const mergedTags =
|
||||
@@ -622,154 +553,19 @@ export class ScoreProcessor implements EventProcessor {
|
||||
this.event = event;
|
||||
}
|
||||
|
||||
static inferDataType(value: string | number): ScoreDataType {
|
||||
return typeof value === "number"
|
||||
? ScoreDataType.NUMERIC
|
||||
: ScoreDataType.CATEGORICAL;
|
||||
}
|
||||
|
||||
static mapStringValueToNumericValue(
|
||||
config: ValidatedScoreConfig,
|
||||
label: string,
|
||||
): number | null {
|
||||
return (
|
||||
config.categories?.find((category) => category.label === label)?.value ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
static inflateScoreBody(
|
||||
body: any,
|
||||
id: string,
|
||||
projectId: string,
|
||||
config?: ValidatedScoreConfig,
|
||||
): Score {
|
||||
const relevantDataType = config?.dataType ?? body.dataType;
|
||||
const scoreProps = { ...body, id, projectId, source: "API" };
|
||||
|
||||
if (typeof body.value === "number") {
|
||||
if (relevantDataType && relevantDataType === ScoreDataType.BOOLEAN) {
|
||||
return {
|
||||
...scoreProps,
|
||||
value: body.value,
|
||||
stringValue: body.value === 1 ? "True" : "False",
|
||||
dataType: ScoreDataType.BOOLEAN,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...scoreProps,
|
||||
value: body.value,
|
||||
dataType: ScoreDataType.NUMERIC,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...scoreProps,
|
||||
value: config
|
||||
? ScoreProcessor.mapStringValueToNumericValue(config, body.value)
|
||||
: null,
|
||||
stringValue: body.value,
|
||||
dataType: ScoreDataType.CATEGORICAL,
|
||||
};
|
||||
}
|
||||
|
||||
validateConfigAgainstBody(body: any, config: ValidatedScoreConfig): void {
|
||||
const { maxValue, minValue, categories, dataType: configDataType } = config;
|
||||
if (body.dataType && body.dataType !== configDataType) {
|
||||
throw new InvalidRequestError(
|
||||
`Data type mismatch based on config: expected ${configDataType}, got ${body.dataType}`,
|
||||
auth(apiScope: ApiAccessScope) {
|
||||
if (apiScope.accessLevel !== "scores" && apiScope.accessLevel !== "all")
|
||||
throw new ForbiddenError(
|
||||
`Access denied for score creation, ${apiScope.accessLevel}`
|
||||
);
|
||||
}
|
||||
|
||||
if (config.isArchived) {
|
||||
throw new InvalidRequestError(
|
||||
"Config is archived and cannot be used to create new scores. Please restore the config first.",
|
||||
);
|
||||
}
|
||||
|
||||
if (config.name !== body.name) {
|
||||
throw new InvalidRequestError(
|
||||
`Name mismatch based on config: expected ${config.name}, got ${body.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
const relevantDataType = configDataType ?? body.dataType;
|
||||
|
||||
const dataTypeValidation = ScoreBodyWithoutConfig.safeParse({
|
||||
...body,
|
||||
dataType: relevantDataType,
|
||||
});
|
||||
if (!dataTypeValidation.success) {
|
||||
throw new InvalidRequestError(
|
||||
`Ingested score body not valid against provided config data type.`,
|
||||
);
|
||||
}
|
||||
|
||||
const rangeValidation = ScorePropsAgainstConfig.safeParse({
|
||||
value: body.value,
|
||||
dataType: relevantDataType,
|
||||
...(maxValue !== null && maxValue !== undefined && { maxValue }),
|
||||
...(minValue !== null && minValue !== undefined && { minValue }),
|
||||
...(categories && { categories }),
|
||||
});
|
||||
if (!rangeValidation.success) {
|
||||
const errorDetails = rangeValidation.error.errors
|
||||
.map((error) => `${error.path.join(".")} - ${error.message}`)
|
||||
.join(", ");
|
||||
throw new InvalidRequestError(
|
||||
`Ingested score body not valid against provided config: ${errorDetails}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async validateAndInflate(
|
||||
body: any,
|
||||
id: string,
|
||||
projectId: string,
|
||||
): Promise<Score> {
|
||||
if (body.configId) {
|
||||
const config = await prisma.scoreConfig.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
id: body.configId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!config || !validateDbScoreConfigSafe(config).success)
|
||||
throw new LangfuseNotFoundError(
|
||||
"The configId you provided does not match a valid config in this project",
|
||||
);
|
||||
|
||||
this.validateConfigAgainstBody(body, config as ValidatedScoreConfig);
|
||||
return ScoreProcessor.inflateScoreBody(
|
||||
body,
|
||||
id,
|
||||
projectId,
|
||||
config as ValidatedScoreConfig,
|
||||
);
|
||||
} else {
|
||||
const validation = ScoreBodyWithoutConfig.safeParse({
|
||||
...body,
|
||||
dataType: body.dataType ?? ScoreProcessor.inferDataType(body.value),
|
||||
});
|
||||
if (!validation.success) {
|
||||
throw new InvalidRequestError(
|
||||
`Ingested score value type not valid against provided data type. Provide numeric values for numeric and boolean scores, and string values for categorical scores.`,
|
||||
);
|
||||
}
|
||||
return ScoreProcessor.inflateScoreBody(body, id, projectId);
|
||||
}
|
||||
}
|
||||
|
||||
async process(
|
||||
apiScope: ApiAccessScope,
|
||||
apiScope: ApiAccessScope
|
||||
): Promise<Trace | Observation | Score> {
|
||||
const { body } = this.event;
|
||||
|
||||
if (apiScope.accessLevel !== "scores" && apiScope.accessLevel !== "all")
|
||||
throw new ForbiddenError(
|
||||
`Access denied for score creation, ${apiScope.accessLevel}`,
|
||||
);
|
||||
this.auth(apiScope);
|
||||
|
||||
const id = body.id ?? v4();
|
||||
|
||||
@@ -783,15 +579,15 @@ export class ScoreProcessor implements EventProcessor {
|
||||
});
|
||||
if (existingScore && existingScore.projectId !== apiScope.projectId) {
|
||||
throw new ForbiddenError(
|
||||
`Access denied for score creation ${existingScore.projectId}`,
|
||||
`Access denied for score creation ${existingScore.projectId}`
|
||||
);
|
||||
}
|
||||
|
||||
const validatedScore = await this.validateAndInflate(
|
||||
const validatedScore = await validateAndInflateScore({
|
||||
body,
|
||||
id,
|
||||
apiScope.projectId,
|
||||
);
|
||||
scoreId: id,
|
||||
projectId: apiScope.projectId,
|
||||
});
|
||||
|
||||
return await prisma.score.upsert({
|
||||
where: {
|
||||
@@ -817,6 +613,10 @@ export class SdkLogProcessor implements EventProcessor {
|
||||
this.event = event;
|
||||
}
|
||||
|
||||
auth(apiScope: ApiAccessScope) {
|
||||
return;
|
||||
}
|
||||
|
||||
process() {
|
||||
try {
|
||||
console.log("SDK Log", this.event);
|
||||
@@ -0,0 +1,71 @@
|
||||
import { type Redis } from "ioredis";
|
||||
import { QueueJobs } from "../../queues";
|
||||
import {
|
||||
getIngestionFlushQueue,
|
||||
IngestionFlushQueue,
|
||||
} from "../../redis/ingestionFlushQueue";
|
||||
import { IngestionUtils } from "../IngestionUtils";
|
||||
import { IngestionEventType } from "../types";
|
||||
import { redis } from "../../redis/redis";
|
||||
import { env } from "../../../env";
|
||||
|
||||
export async function enqueueIngestionEvents(
|
||||
projectId: string,
|
||||
events: IngestionEventType[]
|
||||
) {
|
||||
const ingestionFlushQueue = getIngestionFlushQueue();
|
||||
|
||||
if (!ingestionFlushQueue) {
|
||||
throw Error("IngestionFlushQueue not initialized");
|
||||
}
|
||||
if (!redis) throw Error("Redis connection not available");
|
||||
|
||||
const queuedEventPromises: Promise<void>[] = [];
|
||||
const batchTimestamp = Date.now().toString();
|
||||
|
||||
// Use for loop as TS does not narrow redis type in map function
|
||||
for (const event of events) {
|
||||
queuedEventPromises.push(
|
||||
enqueueSingleIngestionEvent(
|
||||
projectId,
|
||||
event,
|
||||
redis,
|
||||
ingestionFlushQueue,
|
||||
batchTimestamp
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(queuedEventPromises);
|
||||
}
|
||||
|
||||
async function enqueueSingleIngestionEvent(
|
||||
projectId: string,
|
||||
event: IngestionEventType,
|
||||
redis: Redis,
|
||||
ingestionFlushQueue: IngestionFlushQueue,
|
||||
batchTimestamp: string
|
||||
): Promise<void> {
|
||||
if (!("id" in event.body && event.body.id)) {
|
||||
console.warn(
|
||||
`Received ingestion event without id: ${JSON.stringify(event)}`
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const flushKey = IngestionUtils.getFlushKey({
|
||||
entityId: event.body.id,
|
||||
eventType: IngestionUtils.getEventType(event),
|
||||
projectId,
|
||||
batchTimestamp,
|
||||
});
|
||||
const bufferKey = IngestionUtils.getBufferKey(flushKey);
|
||||
const serializedEventData = JSON.stringify({ ...event, projectId });
|
||||
|
||||
await redis.lpush(bufferKey, serializedEventData);
|
||||
await redis.expire(bufferKey, env.LANGFUSE_INGESTION_BUFFER_TTL_SECONDS);
|
||||
await ingestionFlushQueue.add(QueueJobs.FlushIngestionEntity, null, {
|
||||
jobId: flushKey,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { env } from "node:process";
|
||||
import z from "zod";
|
||||
import { ForbiddenError, UnauthorizedError } from "../../../errors";
|
||||
import { eventTypes, ingestionApiSchema, ingestionEvent } from "../types";
|
||||
import {
|
||||
EventProcessor,
|
||||
TraceProcessor,
|
||||
ObservationProcessor,
|
||||
ScoreProcessor,
|
||||
SdkLogProcessor,
|
||||
} from "./EventProcessor";
|
||||
import { EventBodyType, EventName, TraceUpsertEventType } from "../../queues";
|
||||
import {
|
||||
convertTraceUpsertEventsToRedisEvents,
|
||||
getTraceUpsertQueue,
|
||||
} from "../../redis/trace-upsert";
|
||||
import { ApiAccessScope, AuthHeaderVerificationResult } from "../../auth/types";
|
||||
import { redis } from "../../redis/redis";
|
||||
import { backOff } from "exponential-backoff";
|
||||
import { Model } from "../../..";
|
||||
import { enqueueIngestionEvents } from "./enqueueIngestionEvents";
|
||||
|
||||
export type BatchResult = {
|
||||
result: unknown;
|
||||
id: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
type TokenCountInput = {
|
||||
model: Model;
|
||||
text: unknown;
|
||||
};
|
||||
|
||||
export const handleBatch = async (
|
||||
events: z.infer<typeof ingestionApiSchema>["batch"],
|
||||
authCheck: AuthHeaderVerificationResult,
|
||||
calculateTokenDelegate: (p: TokenCountInput) => number | undefined
|
||||
) => {
|
||||
console.log(`handling ingestion ${events.length} events`);
|
||||
|
||||
if (!authCheck.validKey) throw new UnauthorizedError(authCheck.error);
|
||||
|
||||
const results: BatchResult[] = []; // Array to store the results
|
||||
|
||||
const errors: {
|
||||
error: unknown;
|
||||
id: string;
|
||||
type: string;
|
||||
}[] = []; // Array to store the errors
|
||||
|
||||
for (const singleEvent of events) {
|
||||
try {
|
||||
const result = await retry(async () => {
|
||||
return await handleSingleEvent(
|
||||
singleEvent,
|
||||
authCheck.scope,
|
||||
calculateTokenDelegate
|
||||
);
|
||||
});
|
||||
results.push({
|
||||
result: result,
|
||||
id: singleEvent.id,
|
||||
type: singleEvent.type,
|
||||
}); // Push each result into the array
|
||||
} catch (error) {
|
||||
// Handle or log the error if `handleSingleEvent` fails
|
||||
console.error("Error handling event:", error);
|
||||
// Decide how to handle the error: rethrow, continue, or push an error object to results
|
||||
// For example, push an error object:
|
||||
errors.push({
|
||||
error: error,
|
||||
id: singleEvent.id,
|
||||
type: singleEvent.type,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (env.CLICKHOUSE_URL) {
|
||||
try {
|
||||
await enqueueIngestionEvents(authCheck.scope.projectId, events);
|
||||
console.log(`Added ${events.length} ingestion events to queue`);
|
||||
} catch (err) {
|
||||
console.error("Error adding ingestion events to queue", err);
|
||||
}
|
||||
}
|
||||
|
||||
return { results, errors };
|
||||
};
|
||||
|
||||
async function retry<T>(request: () => Promise<T>): Promise<T> {
|
||||
return await backOff(request, {
|
||||
numOfAttempts: env.LANGFUSE_ASYNC_INGESTION_PROCESSING === "true" ? 5 : 3,
|
||||
retry: (e: Error, attemptNumber: number) => {
|
||||
if (e instanceof UnauthorizedError || e instanceof ForbiddenError) {
|
||||
console.log("not retrying auth error");
|
||||
return false;
|
||||
}
|
||||
console.log(`retrying processing events ${attemptNumber}`);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const handleSingleEvent = async (
|
||||
event: z.infer<typeof ingestionEvent>,
|
||||
apiScope: ApiAccessScope,
|
||||
calculateTokenDelegate: (p: {
|
||||
model: Model;
|
||||
text: unknown;
|
||||
}) => number | undefined
|
||||
) => {
|
||||
const { body } = event;
|
||||
let restEvent = body;
|
||||
if ("input" in body) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { input, ...rest } = body;
|
||||
restEvent = rest;
|
||||
}
|
||||
if ("output" in restEvent) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { output, ...rest } = restEvent;
|
||||
restEvent = rest;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`handling single event ${event.id} of type ${event.type}: ${JSON.stringify({ body: restEvent })}`
|
||||
);
|
||||
|
||||
const cleanedEvent = ingestionEvent.parse(cleanEvent(event));
|
||||
|
||||
const { type } = cleanedEvent;
|
||||
|
||||
let processor: EventProcessor;
|
||||
switch (type) {
|
||||
case eventTypes.TRACE_CREATE:
|
||||
processor = new TraceProcessor(cleanedEvent);
|
||||
break;
|
||||
case eventTypes.OBSERVATION_CREATE:
|
||||
case eventTypes.OBSERVATION_UPDATE:
|
||||
case eventTypes.EVENT_CREATE:
|
||||
case eventTypes.SPAN_CREATE:
|
||||
case eventTypes.SPAN_UPDATE:
|
||||
case eventTypes.GENERATION_CREATE:
|
||||
case eventTypes.GENERATION_UPDATE:
|
||||
processor = new ObservationProcessor(
|
||||
cleanedEvent,
|
||||
calculateTokenDelegate
|
||||
);
|
||||
break;
|
||||
case eventTypes.SCORE_CREATE: {
|
||||
processor = new ScoreProcessor(cleanedEvent);
|
||||
break;
|
||||
}
|
||||
case eventTypes.SDK_LOG:
|
||||
processor = new SdkLogProcessor(cleanedEvent);
|
||||
}
|
||||
|
||||
// Deny access to non-score events if the access level is not "all"
|
||||
// This is an additional safeguard to auth checks in EventProcessor
|
||||
if (apiScope.accessLevel !== "all" && type !== eventTypes.SCORE_CREATE) {
|
||||
throw new ForbiddenError("Access denied. Event type not allowed.");
|
||||
}
|
||||
|
||||
return await processor.process(apiScope);
|
||||
};
|
||||
|
||||
// cleans NULL characters from the event
|
||||
export function cleanEvent(obj: unknown): unknown {
|
||||
if (typeof obj === "string") {
|
||||
return obj.replace(/\u0000/g, "");
|
||||
} else if (typeof obj === "object" && obj !== null) {
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(cleanEvent);
|
||||
} else {
|
||||
// Here we assert that obj is a Record<string, unknown>
|
||||
const objAsRecord = obj as Record<string, unknown>;
|
||||
const newObj: Record<string, unknown> = {};
|
||||
for (const key in objAsRecord) {
|
||||
newObj[key] = cleanEvent(objAsRecord[key]);
|
||||
}
|
||||
return newObj;
|
||||
}
|
||||
} else {
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
|
||||
export const isNotNullOrUndefined = <T>(
|
||||
val?: T | null
|
||||
): val is Exclude<T, null | undefined> => !isUndefinedOrNull(val);
|
||||
|
||||
export const isUndefinedOrNull = <T>(val?: T | null): val is undefined | null =>
|
||||
val === undefined || val === null;
|
||||
|
||||
export const sendToWorkerIfEnvironmentConfigured = async (
|
||||
batchResults: BatchResult[],
|
||||
projectId: string
|
||||
): Promise<void> => {
|
||||
const traceEvents: TraceUpsertEventType[] = batchResults
|
||||
.filter((result) => result.type === eventTypes.TRACE_CREATE) // we only have create, no update.
|
||||
.map((result) =>
|
||||
result.result &&
|
||||
typeof result.result === "object" &&
|
||||
"id" in result.result
|
||||
? // ingestion API only gets traces for one projectId
|
||||
{ traceId: result.result.id as string, projectId }
|
||||
: null
|
||||
)
|
||||
.filter(isNotNullOrUndefined);
|
||||
|
||||
try {
|
||||
if (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION && redis) {
|
||||
console.log(`Sending ${traceEvents.length} events to worker via Redis`);
|
||||
|
||||
const queue = getTraceUpsertQueue();
|
||||
if (!queue) {
|
||||
console.error("TraceUpsertQueue not initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
await queue.addBulk(convertTraceUpsertEventsToRedisEvents(traceEvents));
|
||||
} else if (
|
||||
env.LANGFUSE_WORKER_HOST &&
|
||||
env.LANGFUSE_WORKER_PASSWORD &&
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION
|
||||
) {
|
||||
console.log(`Sending ${traceEvents.length} events to worker via HTTP`);
|
||||
const body: EventBodyType = {
|
||||
name: EventName.TraceUpsert,
|
||||
payload: traceEvents,
|
||||
};
|
||||
|
||||
if (traceEvents.length > 0) {
|
||||
await fetch(`${env.LANGFUSE_WORKER_HOST}/api/events`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization:
|
||||
"Basic " +
|
||||
Buffer.from(
|
||||
"admin" + ":" + env.LANGFUSE_WORKER_PASSWORD
|
||||
).toString("base64"),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(8 * 1000),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error sending events to worker", error);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Model, Observation, Prisma } from "@prisma/client";
|
||||
import { prisma } from "../../db";
|
||||
|
||||
export async function findModel(p: {
|
||||
event: {
|
||||
projectId: string;
|
||||
model?: string;
|
||||
unit?: string;
|
||||
startTime?: Date;
|
||||
};
|
||||
existingDbObservation?: Observation;
|
||||
}): Promise<Model | null> {
|
||||
const { event, existingDbObservation } = p;
|
||||
// either get the model from the existing observation
|
||||
// or match pattern on the user provided model name
|
||||
const modelCondition = event.model
|
||||
? Prisma.sql`AND ${event.model} ~ match_pattern`
|
||||
: existingDbObservation?.internalModel
|
||||
? Prisma.sql`AND model_name = ${existingDbObservation.internalModel}`
|
||||
: undefined;
|
||||
if (!modelCondition) return null;
|
||||
|
||||
// unit based on the current event or the existing observation, both can be undefined
|
||||
const mergedUnit = event.unit ?? existingDbObservation?.unit;
|
||||
|
||||
const unitCondition = mergedUnit
|
||||
? Prisma.sql`AND unit = ${mergedUnit}`
|
||||
: Prisma.empty;
|
||||
|
||||
const sql = Prisma.sql`
|
||||
SELECT
|
||||
id,
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
project_id AS "projectId",
|
||||
model_name AS "modelName",
|
||||
match_pattern AS "matchPattern",
|
||||
start_date AS "startDate",
|
||||
input_price AS "inputPrice",
|
||||
output_price AS "outputPrice",
|
||||
total_price AS "totalPrice",
|
||||
unit,
|
||||
tokenizer_id AS "tokenizerId",
|
||||
tokenizer_config AS "tokenizerConfig"
|
||||
FROM
|
||||
models
|
||||
WHERE (project_id = ${event.projectId}
|
||||
OR project_id IS NULL)
|
||||
${modelCondition}
|
||||
${unitCondition}
|
||||
AND (start_date IS NULL OR start_date <= ${
|
||||
event.startTime ? new Date(event.startTime) : new Date()
|
||||
}::timestamp with time zone at time zone 'UTC')
|
||||
ORDER BY
|
||||
project_id ASC,
|
||||
start_date DESC NULLS LAST
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const foundModels = await prisma.$queryRaw<Array<Model>>(sql);
|
||||
|
||||
return foundModels[0] ?? null;
|
||||
}
|
||||
+70
-58
@@ -168,63 +168,39 @@ const BaseScoreBody = z.object({
|
||||
/**
|
||||
* ScoreBody exactly mirrors `PostScoresBody` in the public API. Please refer there for source of truth.
|
||||
*/
|
||||
export const ScoreBody = z
|
||||
.discriminatedUnion("dataType", [
|
||||
BaseScoreBody.merge(
|
||||
z.object({
|
||||
value: z.number(),
|
||||
dataType: z.literal("NUMERIC"),
|
||||
configId: z.string().nullish(),
|
||||
})
|
||||
),
|
||||
BaseScoreBody.merge(
|
||||
z.object({
|
||||
value: z.string(),
|
||||
dataType: z.literal("CATEGORICAL"),
|
||||
configId: z.string().nullish(),
|
||||
})
|
||||
),
|
||||
BaseScoreBody.merge(
|
||||
z.object({
|
||||
value: z.number().refine((val) => val === 0 || val === 1, {
|
||||
message: "Value must be either 0 or 1",
|
||||
}),
|
||||
dataType: z.literal("BOOLEAN"),
|
||||
configId: z.string().nullish(),
|
||||
})
|
||||
),
|
||||
BaseScoreBody.merge(
|
||||
z.object({
|
||||
value: z.union([z.string(), z.number()]),
|
||||
dataType: z.undefined(),
|
||||
configId: z.string().nullish(),
|
||||
})
|
||||
),
|
||||
])
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.dataType) {
|
||||
if (typeof data.value === "number") {
|
||||
if (data.dataType === "CATEGORICAL") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Value must be a string for data type ${data.dataType}`,
|
||||
});
|
||||
}
|
||||
} else if (typeof data.value === "string") {
|
||||
if (data.dataType === "NUMERIC") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Value must be a number for data type ${data.dataType}`,
|
||||
});
|
||||
} else if (data.dataType === "BOOLEAN") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Value must number equal to either 0 or 1 for data type ${data.dataType}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
export const ScoreBody = z.discriminatedUnion("dataType", [
|
||||
BaseScoreBody.merge(
|
||||
z.object({
|
||||
value: z.number(),
|
||||
dataType: z.literal("NUMERIC"),
|
||||
configId: z.string().nullish(),
|
||||
})
|
||||
),
|
||||
BaseScoreBody.merge(
|
||||
z.object({
|
||||
value: z.string(),
|
||||
dataType: z.literal("CATEGORICAL"),
|
||||
configId: z.string().nullish(),
|
||||
})
|
||||
),
|
||||
BaseScoreBody.merge(
|
||||
z.object({
|
||||
value: z.number().refine((value) => value === 0 || value === 1, {
|
||||
message:
|
||||
"Value must be a number equal to either 0 or 1 for data type BOOLEAN",
|
||||
}),
|
||||
dataType: z.literal("BOOLEAN"),
|
||||
configId: z.string().nullish(),
|
||||
})
|
||||
),
|
||||
BaseScoreBody.merge(
|
||||
z.object({
|
||||
value: z.union([z.string(), z.number()]),
|
||||
dataType: z.undefined(),
|
||||
configId: z.string().nullish(),
|
||||
})
|
||||
),
|
||||
]);
|
||||
|
||||
// LEGACY, only required for backwards compatibility
|
||||
export const LegacySpanPostSchema = z.object({
|
||||
@@ -334,6 +310,16 @@ export const SdkLogEvent = z.object({
|
||||
|
||||
// definitions for the ingestion API
|
||||
|
||||
export const observationTypes = [
|
||||
"observation-create",
|
||||
"observation-update",
|
||||
"generation-create",
|
||||
"generation-update",
|
||||
"span-create",
|
||||
"span-update",
|
||||
"event-create",
|
||||
];
|
||||
|
||||
export const eventTypes = {
|
||||
TRACE_CREATE: "trace-create",
|
||||
SCORE_CREATE: "score-create",
|
||||
@@ -343,7 +329,6 @@ export const eventTypes = {
|
||||
GENERATION_CREATE: "generation-create",
|
||||
GENERATION_UPDATE: "generation-update",
|
||||
SDK_LOG: "sdk-log",
|
||||
|
||||
// LEGACY, only required for backwards compatibility
|
||||
OBSERVATION_CREATE: "observation-create",
|
||||
OBSERVATION_UPDATE: "observation-update",
|
||||
@@ -358,6 +343,7 @@ export const traceEvent = base.extend({
|
||||
type: z.literal(eventTypes.TRACE_CREATE),
|
||||
body: TraceBody,
|
||||
});
|
||||
export type TraceEventType = z.infer<typeof traceEvent>;
|
||||
|
||||
export const eventCreateEvent = base.extend({
|
||||
type: z.literal(eventTypes.EVENT_CREATE),
|
||||
@@ -383,6 +369,7 @@ export const scoreEvent = base.extend({
|
||||
type: z.literal(eventTypes.SCORE_CREATE),
|
||||
body: ScoreBody,
|
||||
});
|
||||
export type ScoreEventType = z.infer<typeof scoreEvent>;
|
||||
export const sdkLogEvent = base.extend({
|
||||
type: z.literal(eventTypes.SDK_LOG),
|
||||
body: SdkLogEvent,
|
||||
@@ -409,10 +396,35 @@ export const ingestionEvent = z.discriminatedUnion("type", [
|
||||
legacyObservationCreateEvent,
|
||||
legacyObservationUpdateEvent,
|
||||
]);
|
||||
export type IngestionEventType = z.infer<typeof ingestionEvent>;
|
||||
|
||||
export const ingestionBatchEvent = z.array(ingestionEvent);
|
||||
export type IngestionBatchEventType = z.infer<typeof ingestionBatchEvent>;
|
||||
|
||||
export const ingestionEventWithProjectId = ingestionEvent.and(
|
||||
z.object({ projectId: z.string() })
|
||||
);
|
||||
export type IngestionEventWithProjectIdType = z.infer<
|
||||
typeof ingestionEventWithProjectId
|
||||
>;
|
||||
|
||||
export const ingestionApiSchema = z.object({
|
||||
batch: ingestionBatchEvent,
|
||||
metadata: jsonSchema.nullish(),
|
||||
});
|
||||
|
||||
export const ingestionApiSchemaWithProjectId = ingestionApiSchema.extend({
|
||||
projectId: z.string(),
|
||||
});
|
||||
export type IngestionApiSchemaWithProjectId = z.infer<
|
||||
typeof ingestionApiSchemaWithProjectId
|
||||
>;
|
||||
|
||||
export type ObservationEvent =
|
||||
| z.infer<typeof legacyObservationCreateEvent>
|
||||
| z.infer<typeof legacyObservationUpdateEvent>
|
||||
| z.infer<typeof eventCreateEvent>
|
||||
| z.infer<typeof spanCreateEvent>
|
||||
| z.infer<typeof spanUpdateEvent>
|
||||
| z.infer<typeof generationCreateEvent>
|
||||
| z.infer<typeof generationUpdateEvent>;
|
||||
@@ -0,0 +1,160 @@
|
||||
import {
|
||||
ScoreBodyWithoutConfig,
|
||||
ScorePropsAgainstConfig,
|
||||
validateDbScoreConfigSafe,
|
||||
ValidatedScoreConfig,
|
||||
} from "../../../src";
|
||||
import { prisma, Score, ScoreDataType } from "../../db";
|
||||
|
||||
import { InvalidRequestError, LangfuseNotFoundError } from "../../errors";
|
||||
|
||||
type ValidateAndInflateScoreParams = {
|
||||
projectId: string;
|
||||
scoreId: string;
|
||||
body: any;
|
||||
};
|
||||
|
||||
export async function validateAndInflateScore(
|
||||
params: ValidateAndInflateScoreParams
|
||||
): Promise<Score> {
|
||||
const { body, projectId } = params;
|
||||
|
||||
if (body.configId) {
|
||||
const config = await prisma.scoreConfig.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
id: body.configId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!config || !validateDbScoreConfigSafe(config).success)
|
||||
throw new LangfuseNotFoundError(
|
||||
"The configId you provided does not match a valid config in this project"
|
||||
);
|
||||
|
||||
validateConfigAgainstBody(body, config as ValidatedScoreConfig);
|
||||
|
||||
return inflateScoreBody({
|
||||
...params,
|
||||
config: config as ValidatedScoreConfig,
|
||||
});
|
||||
}
|
||||
|
||||
const validation = ScoreBodyWithoutConfig.safeParse({
|
||||
...body,
|
||||
dataType: body.dataType ?? inferDataType(body.value),
|
||||
});
|
||||
|
||||
if (!validation.success) {
|
||||
throw new InvalidRequestError(
|
||||
`Ingested score value type not valid against provided data type. Provide numeric values for numeric and boolean scores, and string values for categorical scores.`
|
||||
);
|
||||
}
|
||||
|
||||
return inflateScoreBody(params);
|
||||
}
|
||||
|
||||
function inferDataType(value: string | number): ScoreDataType {
|
||||
return typeof value === "number"
|
||||
? ScoreDataType.NUMERIC
|
||||
: ScoreDataType.CATEGORICAL;
|
||||
}
|
||||
|
||||
function mapStringValueToNumericValue(
|
||||
config: ValidatedScoreConfig,
|
||||
label: string
|
||||
): number | null {
|
||||
return (
|
||||
config.categories?.find((category) => category.label === label)?.value ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function inflateScoreBody(
|
||||
params: ValidateAndInflateScoreParams & { config?: ValidatedScoreConfig }
|
||||
): Score {
|
||||
const { body, projectId, scoreId, config } = params;
|
||||
|
||||
const relevantDataType = config?.dataType ?? body.dataType;
|
||||
const scoreProps = { ...body, id: scoreId, projectId, source: "API" };
|
||||
|
||||
if (typeof body.value === "number") {
|
||||
if (relevantDataType && relevantDataType === ScoreDataType.BOOLEAN) {
|
||||
return {
|
||||
...scoreProps,
|
||||
value: body.value,
|
||||
stringValue: body.value === 1 ? "True" : "False",
|
||||
dataType: ScoreDataType.BOOLEAN,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...scoreProps,
|
||||
value: body.value,
|
||||
dataType: ScoreDataType.NUMERIC,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...scoreProps,
|
||||
value: config ? mapStringValueToNumericValue(config, body.value) : null,
|
||||
stringValue: body.value,
|
||||
dataType: ScoreDataType.CATEGORICAL,
|
||||
};
|
||||
}
|
||||
|
||||
function validateConfigAgainstBody(
|
||||
body: any,
|
||||
config: ValidatedScoreConfig
|
||||
): void {
|
||||
const { maxValue, minValue, categories, dataType: configDataType } = config;
|
||||
|
||||
if (body.dataType && body.dataType !== configDataType) {
|
||||
throw new InvalidRequestError(
|
||||
`Data type mismatch based on config: expected ${configDataType}, got ${body.dataType}`
|
||||
);
|
||||
}
|
||||
|
||||
if (config.isArchived) {
|
||||
throw new InvalidRequestError(
|
||||
"Config is archived and cannot be used to create new scores. Please restore the config first."
|
||||
);
|
||||
}
|
||||
|
||||
if (config.name !== body.name) {
|
||||
throw new InvalidRequestError(
|
||||
`Name mismatch based on config: expected ${config.name}, got ${body.name}`
|
||||
);
|
||||
}
|
||||
|
||||
const relevantDataType = configDataType ?? body.dataType;
|
||||
|
||||
const dataTypeValidation = ScoreBodyWithoutConfig.safeParse({
|
||||
...body,
|
||||
dataType: relevantDataType,
|
||||
});
|
||||
|
||||
if (!dataTypeValidation.success) {
|
||||
throw new InvalidRequestError(
|
||||
`Ingested score body not valid against provided config data type.`
|
||||
);
|
||||
}
|
||||
|
||||
const rangeValidation = ScorePropsAgainstConfig.safeParse({
|
||||
value: body.value,
|
||||
dataType: relevantDataType,
|
||||
...(maxValue !== null && maxValue !== undefined && { maxValue }),
|
||||
...(minValue !== null && minValue !== undefined && { minValue }),
|
||||
...(categories && { categories }),
|
||||
});
|
||||
|
||||
if (!rangeValidation.success) {
|
||||
const errorDetails = rangeValidation.error.errors
|
||||
.map((error) => `${error.path.join(".")} - ${error.message}`)
|
||||
.join(", ");
|
||||
|
||||
throw new InvalidRequestError(
|
||||
`Ingested score body not valid against provided config: ${errorDetails}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -45,8 +45,10 @@ export const ZodModelConfig = z.object({
|
||||
top_p: z.coerce.number().optional(),
|
||||
});
|
||||
|
||||
// NOTE: Update docs page when changing this!
|
||||
export const openAIModels = [
|
||||
"gpt-4o",
|
||||
"gpt-4o-2024-08-06",
|
||||
"gpt-4o-2024-05-13",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4o-mini-2024-07-18",
|
||||
@@ -66,6 +68,7 @@ export const openAIModels = [
|
||||
|
||||
export type OpenAIModel = (typeof openAIModels)[number];
|
||||
|
||||
// NOTE: Update docs page when changing this!
|
||||
export const anthropicModels = [
|
||||
"claude-3-5-sonnet-20240620",
|
||||
"claude-3-opus-20240229",
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
import { z } from "zod";
|
||||
import { ingestionBatchEvent } from ".";
|
||||
|
||||
export enum EventName {
|
||||
TraceUpsert = "TraceUpsert",
|
||||
BatchExport = "BatchExport",
|
||||
EvaluationExecution = "EvaluationExecution",
|
||||
LegacyIngestion = "LegacyIngestion",
|
||||
}
|
||||
|
||||
export const LegacyIngestionEvent = z.object({
|
||||
data: ingestionBatchEvent,
|
||||
authCheck: z.object({
|
||||
validKey: z.literal(true),
|
||||
scope: z.object({
|
||||
projectId: z.string(),
|
||||
accessLevel: z.enum(["all", "scores"]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const BatchExportJobSchema = z.object({
|
||||
projectId: z.string(),
|
||||
batchExportId: z.string(),
|
||||
@@ -22,6 +35,7 @@ export const EvalExecutionEvent = z.object({
|
||||
export type BatchExportJobType = z.infer<typeof BatchExportJobSchema>;
|
||||
export type TraceUpsertEventType = z.infer<typeof TraceUpsertEventSchema>;
|
||||
export type EvalExecutionEventType = z.infer<typeof EvalExecutionEvent>;
|
||||
export type LegacyIngestionEventType = z.infer<typeof LegacyIngestionEvent>;
|
||||
|
||||
export const EventBodySchema = z.union([
|
||||
z.object({
|
||||
@@ -44,6 +58,8 @@ export enum QueueName {
|
||||
EvaluationExecution = "evaluation-execution-queue", // Worker executes Evals
|
||||
BatchExport = "batch-export-queue",
|
||||
RepeatQueue = "repeat-queue",
|
||||
IngestionFlushQueue = "ingestion-flush-queue",
|
||||
LegacyIngestionQueue = "legacy-ingestion-queue",
|
||||
}
|
||||
|
||||
export enum QueueJobs {
|
||||
@@ -51,6 +67,8 @@ export enum QueueJobs {
|
||||
EvaluationExecution = "evaluation-execution-job",
|
||||
BatchExportJob = "batch-export-job",
|
||||
EnqueueBatchExportJobs = "enqueue-batch-export-jobs",
|
||||
FlushIngestionEntity = "flush-ingestion-entity",
|
||||
LegacyIngestionJob = "legacy-ingestion-job",
|
||||
}
|
||||
|
||||
export type TQueueJobTypes = {
|
||||
@@ -72,4 +90,10 @@ export type TQueueJobTypes = {
|
||||
payload: BatchExportJobType;
|
||||
name: QueueJobs.BatchExportJob;
|
||||
};
|
||||
[QueueName.LegacyIngestionQueue]: {
|
||||
timestamp: Date;
|
||||
id: string;
|
||||
payload: LegacyIngestionEventType;
|
||||
name: QueueJobs.LegacyIngestionJob;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Queue } from "bullmq";
|
||||
import { QueueName, TQueueJobTypes } from "../queues";
|
||||
import { redis } from "./redis";
|
||||
|
||||
let batchExportQueue: Queue<TQueueJobTypes[QueueName.BatchExport]> | null =
|
||||
null;
|
||||
|
||||
export const getBatchExportQueue = () => {
|
||||
if (batchExportQueue) return batchExportQueue;
|
||||
|
||||
batchExportQueue = redis
|
||||
? new Queue<TQueueJobTypes[QueueName.BatchExport]>(QueueName.BatchExport, {
|
||||
connection: redis,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 100,
|
||||
attempts: 2,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
return batchExportQueue;
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Queue } from "bullmq";
|
||||
|
||||
import { env } from "../../env";
|
||||
import { redis } from "../redis/redis";
|
||||
import { QueueName } from "../queues";
|
||||
|
||||
export type IngestionFlushQueue = Queue<null>;
|
||||
|
||||
let ingestionFlushQueue: IngestionFlushQueue | null = null;
|
||||
|
||||
export const getIngestionFlushQueue = () => {
|
||||
if (ingestionFlushQueue) return ingestionFlushQueue;
|
||||
|
||||
ingestionFlushQueue = redis
|
||||
? new Queue<null>(QueueName.IngestionFlushQueue, {
|
||||
connection: redis,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true, // Important: If not true, new jobs for that ID would be ignored as jobs in the complete set are still considered as part of the queue
|
||||
removeOnFail: 1000,
|
||||
delay: env.LANGFUSE_INGESTION_FLUSH_DELAY_MS,
|
||||
attempts: env.LANGFUSE_INGESTION_FLUSH_ATTEMPTS,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
return ingestionFlushQueue;
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Queue } from "bullmq";
|
||||
import { QueueName, TQueueJobTypes } from "../queues";
|
||||
import { redis } from "./redis";
|
||||
|
||||
let legacyIngestionQueue: Queue<
|
||||
TQueueJobTypes[QueueName.LegacyIngestionQueue]
|
||||
> | null = null;
|
||||
|
||||
export const getLegacyIngestionQueue = () => {
|
||||
if (legacyIngestionQueue) return legacyIngestionQueue;
|
||||
|
||||
legacyIngestionQueue = redis
|
||||
? new Queue<TQueueJobTypes[QueueName.LegacyIngestionQueue]>(
|
||||
QueueName.LegacyIngestionQueue,
|
||||
{
|
||||
connection: redis,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 100,
|
||||
attempts: 5,
|
||||
},
|
||||
}
|
||||
)
|
||||
: null;
|
||||
|
||||
return legacyIngestionQueue;
|
||||
};
|
||||
@@ -1,23 +1,23 @@
|
||||
import Redis from "ioredis";
|
||||
import { env } from "./env";
|
||||
import logger from "./logger";
|
||||
import { env } from "../../env";
|
||||
|
||||
const createRedisClient = () => {
|
||||
try {
|
||||
return env.REDIS_CONNECTION_STRING
|
||||
? new Redis(env.REDIS_CONNECTION_STRING, { maxRetriesPerRequest: null })
|
||||
: new Redis({
|
||||
host: String(env.REDIS_HOST),
|
||||
port: Number(env.REDIS_PORT),
|
||||
password: String(env.REDIS_AUTH),
|
||||
maxRetriesPerRequest: null, // Set to `null` to disable retrying
|
||||
});
|
||||
: env.REDIS_HOST
|
||||
? new Redis({
|
||||
host: String(env.REDIS_HOST),
|
||||
port: Number(env.REDIS_PORT),
|
||||
password: String(env.REDIS_AUTH),
|
||||
maxRetriesPerRequest: null, // Set to `null` to disable retrying
|
||||
})
|
||||
: null;
|
||||
} catch (e) {
|
||||
logger.error(e, "Failed to connect to redis");
|
||||
console.error(e, "Failed to connect to redis");
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var redis: undefined | ReturnType<typeof createRedisClient>;
|
||||
@@ -0,0 +1,65 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import {
|
||||
QueueJobs,
|
||||
QueueName,
|
||||
TQueueJobTypes,
|
||||
TraceUpsertEventType,
|
||||
} from "../queues";
|
||||
import { Queue } from "bullmq";
|
||||
import { redis } from "./redis";
|
||||
|
||||
let traceUpsertQueue: Queue<TQueueJobTypes[QueueName.TraceUpsert]> | null =
|
||||
null;
|
||||
|
||||
export const getTraceUpsertQueue = () => {
|
||||
if (traceUpsertQueue) return traceUpsertQueue;
|
||||
|
||||
traceUpsertQueue = redis
|
||||
? new Queue<TQueueJobTypes[QueueName.TraceUpsert]>(QueueName.TraceUpsert, {
|
||||
connection: redis,
|
||||
})
|
||||
: null;
|
||||
|
||||
return traceUpsertQueue;
|
||||
};
|
||||
|
||||
export function convertTraceUpsertEventsToRedisEvents(
|
||||
events: TraceUpsertEventType[]
|
||||
) {
|
||||
const uniqueTracesPerProject = events.reduce((acc, event) => {
|
||||
if (!acc.get(event.projectId)) {
|
||||
acc.set(event.projectId, new Set());
|
||||
}
|
||||
acc.get(event.projectId)?.add(event.traceId);
|
||||
return acc;
|
||||
}, new Map<string, Set<string>>());
|
||||
|
||||
const jobs = [...uniqueTracesPerProject.entries()]
|
||||
.map((tracesPerProject) => {
|
||||
const [projectId, traceIds] = tracesPerProject;
|
||||
|
||||
return [...traceIds].map((traceId) => ({
|
||||
name: QueueJobs.TraceUpsert,
|
||||
data: {
|
||||
payload: {
|
||||
projectId,
|
||||
traceId,
|
||||
},
|
||||
id: randomUUID(),
|
||||
timestamp: new Date(),
|
||||
name: QueueJobs.TraceUpsert as const,
|
||||
},
|
||||
opts: {
|
||||
removeOnFail: 10000,
|
||||
removeOnComplete: true,
|
||||
attempts: 5,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 1000,
|
||||
},
|
||||
},
|
||||
}));
|
||||
})
|
||||
.flat();
|
||||
return jobs;
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { Prompt, PrismaClient } from "@prisma/client";
|
||||
import { Redis } from "ioredis";
|
||||
import { env } from "../../env";
|
||||
|
||||
export class PromptService {
|
||||
private cacheEnabled: boolean;
|
||||
private ttlSeconds: number;
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaClient,
|
||||
private redis: Redis | null,
|
||||
private metricIncrementer?: // used for Sentry metrics
|
||||
(name: string, value?: number) => void,
|
||||
cacheEnabled?: boolean // used for testing
|
||||
) {
|
||||
this.cacheEnabled =
|
||||
Boolean(redis) &&
|
||||
(cacheEnabled || env.LANGFUSE_CACHE_PROMPT_ENABLED === "true");
|
||||
|
||||
this.ttlSeconds = env.LANGFUSE_CACHE_PROMPT_TTL_SECONDS;
|
||||
}
|
||||
|
||||
public async getPrompt(params: PromptParams): Promise<Prompt | null> {
|
||||
if (await this.shouldUseCache(params)) {
|
||||
const cachedPrompt = await this.getCachedPrompt(params);
|
||||
|
||||
this.incrementMetric(
|
||||
cachedPrompt ? Metrics.PromptCacheHit : Metrics.PromptCacheMiss
|
||||
);
|
||||
|
||||
if (cachedPrompt) {
|
||||
this.logInfo("Returning cached prompt for params", params);
|
||||
|
||||
return cachedPrompt;
|
||||
}
|
||||
}
|
||||
|
||||
const dbPrompt = await this.getDbPrompt(params);
|
||||
|
||||
if ((await this.shouldUseCache(params)) && dbPrompt) {
|
||||
await this.cachePrompt({ ...params, prompt: dbPrompt });
|
||||
|
||||
this.logInfo("Successfully cached prompt for params", params);
|
||||
}
|
||||
|
||||
this.logInfo("Returning DB prompt for params", params);
|
||||
|
||||
return dbPrompt;
|
||||
}
|
||||
|
||||
private async getDbPrompt(params: PromptParams): Promise<Prompt | null> {
|
||||
const { projectId, promptName, version, label } = params;
|
||||
|
||||
if (version) {
|
||||
return await this.prisma.prompt.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
name: promptName,
|
||||
version,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (label) {
|
||||
return await this.prisma.prompt.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
name: promptName,
|
||||
labels: {
|
||||
has: label,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this.logError("Invalid prompt params", params);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async shouldUseCache(params: PromptParams): Promise<boolean> {
|
||||
if (!this.cacheEnabled) return false;
|
||||
|
||||
const isLocked = await this.isCacheLocked(params);
|
||||
|
||||
if (isLocked) {
|
||||
this.logInfo("Cache is locked for params", params);
|
||||
}
|
||||
|
||||
return !isLocked;
|
||||
}
|
||||
|
||||
private async getCachedPrompt(params: PromptParams): Promise<Prompt | null> {
|
||||
try {
|
||||
const key = this.getCacheKey(params);
|
||||
const value = await this.redis?.getex(key, "EX", this.ttlSeconds);
|
||||
|
||||
if (value) return JSON.parse(value) as Prompt;
|
||||
} catch (e) {
|
||||
this.logError("Error getting cached prompt", e);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async cachePrompt(params: PromptParams & { prompt: Prompt }) {
|
||||
try {
|
||||
const keyIndexKey = this.getKeyIndexKey(params);
|
||||
const key = this.getCacheKey(params);
|
||||
const value = JSON.stringify(params.prompt);
|
||||
|
||||
await this.redis?.sadd(keyIndexKey, key);
|
||||
await this.redis?.set(key, value, "EX", this.ttlSeconds);
|
||||
} catch (e) {
|
||||
this.logError("Error caching prompt", e);
|
||||
}
|
||||
}
|
||||
|
||||
public async lockCache(
|
||||
params: Pick<PromptParams, "projectId" | "promptName">
|
||||
): Promise<void> {
|
||||
if (!this.cacheEnabled) return;
|
||||
|
||||
const lockKey = this.getLockKey(params);
|
||||
|
||||
try {
|
||||
await this.redis?.setex(lockKey, 30, "locked");
|
||||
} catch (e) {
|
||||
this.logError("Error locking cache key prefix", lockKey, e);
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async unlockCache(
|
||||
params: Pick<PromptParams, "projectId" | "promptName">
|
||||
): Promise<void> {
|
||||
if (!this.cacheEnabled) return;
|
||||
|
||||
const lockKey = this.getLockKey(params);
|
||||
|
||||
try {
|
||||
await this.redis?.del(lockKey);
|
||||
} catch (e) {
|
||||
this.logError("Error unlocking cache key prefix", lockKey, e);
|
||||
|
||||
// Don't re-throw error as lock TTL is short and it's not critical
|
||||
}
|
||||
}
|
||||
|
||||
private async isCacheLocked(
|
||||
params: Pick<PromptParams, "projectId" | "promptName">
|
||||
): Promise<boolean> {
|
||||
const lockKey = this.getLockKey(params);
|
||||
|
||||
try {
|
||||
return Boolean(await this.redis?.exists(lockKey));
|
||||
} catch (e) {
|
||||
this.logError("Error checking if cache is locked", lockKey, e);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private getLockKey(
|
||||
params: Pick<PromptParams, "projectId" | "promptName">
|
||||
): string {
|
||||
// Important to *pre*fix LOCK as otherwise it would be deleted by deleteKeysByPrefix
|
||||
return `LOCK:${this.getCacheKeyPrefix(params)}`;
|
||||
}
|
||||
|
||||
public async invalidateCache(
|
||||
params: Pick<PromptParams, "projectId" | "promptName">
|
||||
): Promise<void> {
|
||||
if (!this.cacheEnabled) return;
|
||||
|
||||
const cacheKeyPrefix = this.getCacheKeyPrefix(params);
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
this.logInfo("Invalidating cache for prefix", cacheKeyPrefix);
|
||||
|
||||
const keyIndexKey = this.getKeyIndexKey(params);
|
||||
const keys = await this.redis?.smembers(keyIndexKey);
|
||||
|
||||
// Delete all keys for the prefix and the key index
|
||||
await this.redis?.del([...(keys ?? []), keyIndexKey]);
|
||||
|
||||
this.logInfo(
|
||||
`Cache invalidated for prefix ${cacheKeyPrefix} in ${Date.now() - startTime}ms`
|
||||
);
|
||||
} catch (e) {
|
||||
this.logError("Error deleting keys for prefix", cacheKeyPrefix, e);
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private getCacheKey(params: PromptParams): string {
|
||||
const prefix = this.getCacheKeyPrefix(params);
|
||||
|
||||
return `${prefix}:${params.version ?? params.label}`;
|
||||
}
|
||||
|
||||
private getCacheKeyPrefix(
|
||||
params: Pick<PromptParams, "projectId" | "promptName">
|
||||
): string {
|
||||
return `prompt:${params.projectId}:${params.promptName}`;
|
||||
}
|
||||
|
||||
private getKeyIndexKey(
|
||||
params: Pick<PromptParams, "projectId" | "promptName">
|
||||
): string {
|
||||
return `prompt_key_index:${params.projectId}:${params.promptName}`;
|
||||
}
|
||||
|
||||
private logError(message: string, ...args: any[]) {
|
||||
console.error(`[PromptService] ${message}`, ...args);
|
||||
}
|
||||
|
||||
private logInfo(message: string, ...args: any[]) {
|
||||
console.log(`[PromptService] ${message}`, ...args);
|
||||
}
|
||||
|
||||
private incrementMetric(name: Metrics, value: number = 1) {
|
||||
try {
|
||||
this.metricIncrementer?.(name, value);
|
||||
} catch (e) {
|
||||
this.logError("Error incrementing metric", name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type PromptParams = {
|
||||
projectId: string;
|
||||
promptName: string;
|
||||
} & (
|
||||
| { version: number; label: undefined }
|
||||
| { version: null | undefined; label: string }
|
||||
);
|
||||
|
||||
enum Metrics {
|
||||
PromptCacheHit = "prompt_cache_hit",
|
||||
PromptCacheMiss = "prompt_cache_miss",
|
||||
}
|
||||
+10
-9
@@ -15,25 +15,25 @@ import {
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
|
||||
interface ProjectInvitationTemplateProps {
|
||||
interface MembershipInvitationTemplateProps {
|
||||
invitedByUsername: string;
|
||||
invitedByUserEmail: string;
|
||||
projectName: string;
|
||||
orgName: string;
|
||||
receiverEmail: string;
|
||||
inviteLink: string;
|
||||
emailFromAddress: string;
|
||||
langfuseCloudRegion?: string;
|
||||
}
|
||||
|
||||
export const ProjectInvitationTemplate = ({
|
||||
export const MembershipInvitationTemplate = ({
|
||||
invitedByUsername,
|
||||
invitedByUserEmail,
|
||||
projectName,
|
||||
orgName,
|
||||
receiverEmail,
|
||||
inviteLink,
|
||||
emailFromAddress,
|
||||
langfuseCloudRegion,
|
||||
}: ProjectInvitationTemplateProps) => {
|
||||
}: MembershipInvitationTemplateProps) => {
|
||||
const previewText = `Join ${invitedByUsername} on Langfuse`;
|
||||
|
||||
return (
|
||||
@@ -53,7 +53,7 @@ export const ProjectInvitationTemplate = ({
|
||||
/>
|
||||
</Section>
|
||||
<Heading className="mx-0 my-[30px] p-0 text-center text-2xl font-normal text-black">
|
||||
Join <strong>{projectName}</strong> on <strong>Langfuse</strong>
|
||||
Join <strong>{orgName}</strong> on <strong>Langfuse</strong>
|
||||
</Heading>
|
||||
<Text className="text-sm leading-6 text-black">Hello,</Text>
|
||||
<Text className="text-sm leading-6 text-black">
|
||||
@@ -64,7 +64,8 @@ export const ProjectInvitationTemplate = ({
|
||||
>
|
||||
{invitedByUserEmail}
|
||||
</Link>
|
||||
) has invited you to the <strong>{projectName}</strong> project on
|
||||
) has invited you to join the <strong>{orgName}</strong>{" "}
|
||||
organization on
|
||||
{langfuseCloudRegion
|
||||
? ` Langfuse (${langfuseCloudRegion} data region)`
|
||||
: " Langfuse"}
|
||||
@@ -78,7 +79,7 @@ export const ProjectInvitationTemplate = ({
|
||||
Accept Invitation
|
||||
</Button>
|
||||
<Text className="mt-2 text-xs leading-3 text-muted-foreground">
|
||||
(you need to create an account)
|
||||
(you need to create an account with this email address)
|
||||
</Text>
|
||||
</Section>
|
||||
<Text className="text-sm leading-6 text-black">
|
||||
@@ -102,4 +103,4 @@ export const ProjectInvitationTemplate = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectInvitationTemplate;
|
||||
export default MembershipInvitationTemplate;
|
||||
+11
-14
@@ -2,7 +2,7 @@ import { createTransport } from "nodemailer";
|
||||
import { parseConnectionUrl } from "nodemailer/lib/shared/index.js";
|
||||
import { render } from "@react-email/render";
|
||||
|
||||
import ProjectInvitationTemplate from "./ProjectInvitationEmailTemplate";
|
||||
import MembershipInvitationTemplate from "./MembershipInvitationEmailTemplate";
|
||||
|
||||
const langfuseUrls = {
|
||||
US: "https://us.cloud.langfuse.com",
|
||||
@@ -10,7 +10,7 @@ const langfuseUrls = {
|
||||
STAGING: "https://staging.langfuse.com",
|
||||
};
|
||||
|
||||
type SendProjectInvitationParams = {
|
||||
type SendMembershipInvitationParams = {
|
||||
env: Partial<
|
||||
Record<
|
||||
| "EMAIL_FROM_ADDRESS"
|
||||
@@ -23,19 +23,19 @@ type SendProjectInvitationParams = {
|
||||
to: string;
|
||||
inviterName: string;
|
||||
inviterEmail: string;
|
||||
projectName: string;
|
||||
orgName: string;
|
||||
};
|
||||
|
||||
export const sendProjectInvitationEmail = async ({
|
||||
export const sendMembershipInvitationEmail = async ({
|
||||
env,
|
||||
to,
|
||||
inviterName,
|
||||
inviterEmail,
|
||||
projectName,
|
||||
}: SendProjectInvitationParams) => {
|
||||
orgName,
|
||||
}: SendMembershipInvitationParams) => {
|
||||
if (!env.EMAIL_FROM_ADDRESS || !env.SMTP_CONNECTION_URL) {
|
||||
console.error(
|
||||
"Missing environment variables for sending project invitation email."
|
||||
"Missing environment variables for sending membership invitation email."
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -59,10 +59,10 @@ export const sendProjectInvitationEmail = async ({
|
||||
const mailer = createTransport(parseConnectionUrl(env.SMTP_CONNECTION_URL));
|
||||
|
||||
const htmlTemplate = render(
|
||||
ProjectInvitationTemplate({
|
||||
MembershipInvitationTemplate({
|
||||
invitedByUsername: inviterName,
|
||||
invitedByUserEmail: inviterEmail,
|
||||
projectName: projectName,
|
||||
orgName: orgName,
|
||||
receiverEmail: to,
|
||||
inviteLink: authUrl,
|
||||
emailFromAddress: env.EMAIL_FROM_ADDRESS,
|
||||
@@ -72,11 +72,8 @@ export const sendProjectInvitationEmail = async ({
|
||||
|
||||
await mailer.sendMail({
|
||||
to,
|
||||
from: {
|
||||
address: env.EMAIL_FROM_ADDRESS,
|
||||
name: "Langfuse",
|
||||
},
|
||||
subject: `${inviterName} invited you to join "${projectName}"`,
|
||||
from: `Langfuse <${env.EMAIL_FROM_ADDRESS}>`,
|
||||
subject: `${inviterName} invited you to join "${orgName}" organization on Langfuse`,
|
||||
html: htmlTemplate,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type z } from "zod";
|
||||
import { z } from "zod";
|
||||
import lodash from "lodash";
|
||||
import { jsonSchema } from "./zod";
|
||||
import { JsonNested, jsonSchema, jsonSchemaNullable } from "./zod";
|
||||
|
||||
export const parseJson = (input: string) => {
|
||||
try {
|
||||
@@ -57,3 +57,52 @@ export const mergeJson = (
|
||||
}
|
||||
return lodash.merge(json1, json2);
|
||||
};
|
||||
|
||||
export const parseJsonPrioritised = (
|
||||
json: string
|
||||
): 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;
|
||||
}
|
||||
};
|
||||
|
||||
export const convertRecordToJsonSchema = (
|
||||
record: Record<string, string>
|
||||
): JsonNested | undefined => {
|
||||
const jsonSchema: JsonNested = {};
|
||||
|
||||
// if record is empty, return undefined
|
||||
if (Object.keys(record).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const key in record) {
|
||||
try {
|
||||
jsonSchema[key] = JSON.parse(record[key]);
|
||||
} catch (e) {
|
||||
jsonSchema[key] = record[key];
|
||||
}
|
||||
}
|
||||
|
||||
return jsonSchema;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
type OmitKeys<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
|
||||
|
||||
/**
|
||||
* Removes specified keys from an object and returns a new object without those keys.
|
||||
*/
|
||||
|
||||
export function removeObjectKeys<T, K extends keyof T>(
|
||||
obj: T,
|
||||
keys: K[]
|
||||
): OmitKeys<T, K> {
|
||||
const result = { ...obj };
|
||||
for (const key of keys) {
|
||||
delete result[key];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,2 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const isPresent = <T>(value: T | null | undefined): value is T =>
|
||||
value !== null && value !== undefined && value !== "";
|
||||
|
||||
export const stringDateTime = z.string().datetime({ offset: true }).nullish();
|
||||
@@ -17,11 +17,11 @@ const nestedLiteralSchema = z.union([
|
||||
type Root = z.infer<typeof rootLiteralSchema>;
|
||||
type Literal = z.infer<typeof nestedLiteralSchema>;
|
||||
|
||||
type JsonNested = Literal | { [key: string]: JsonNested } | JsonNested[];
|
||||
export type JsonNested = Literal | { [key: string]: JsonNested } | JsonNested[];
|
||||
type Json = Root | { [key: string]: JsonNested } | JsonNested[];
|
||||
|
||||
// Here, you define the schema recursively
|
||||
const jsonSchemaNullable: z.ZodType<JsonNested> = z.lazy(() =>
|
||||
export const jsonSchemaNullable: z.ZodType<JsonNested> = z.lazy(() =>
|
||||
z.union([
|
||||
nestedLiteralSchema,
|
||||
z.array(jsonSchemaNullable),
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"jsx": "react",
|
||||
"moduleResolution": "NodeNext",
|
||||
"module": "NodeNext",
|
||||
"lib": ["ES2020"],
|
||||
"lib": ["ES2021"],
|
||||
"outDir": "./dist",
|
||||
"types": ["node"],
|
||||
"target": "ES2020",
|
||||
|
||||
Generated
+1766
-1598
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -34,13 +34,13 @@ ENV NEXT_MANUAL_SIG_HANDLE true
|
||||
|
||||
# pass public variables in build step
|
||||
ARG NEXT_PUBLIC_LANGFUSE_CLOUD_REGION
|
||||
ARG NEXT_PUBLIC_DEMO_ORG_ID
|
||||
ARG NEXT_PUBLIC_DEMO_PROJECT_ID
|
||||
ARG NEXT_PUBLIC_SIGN_UP_DISABLED
|
||||
ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY
|
||||
ARG NEXT_PUBLIC_POSTHOG_KEY
|
||||
ARG NEXT_PUBLIC_POSTHOG_HOST
|
||||
ARG NEXT_PUBLIC_CRISP_WEBSITE_ID
|
||||
ARG LANFUSE_WEB_MIGRATION_DISABLED
|
||||
|
||||
# Copy source code of isolated subworkspace
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
@@ -74,7 +74,7 @@ RUN apk add --no-cache dumb-init
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
RUN npm install -g --no-package-lock --no-save prisma
|
||||
RUN npm install -g --no-package-lock --no-save prisma@5.13.0
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/web/next.config.mjs .
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/web/package.json .
|
||||
|
||||
@@ -16,6 +16,8 @@ const serverTestConfig = {
|
||||
displayName: "server",
|
||||
testMatch: ["/**/*.servertest.[jt]s?(x)"],
|
||||
testEnvironment: "jest-environment-node",
|
||||
setupFilesAfterEnv: ["<rootDir>/src/__tests__/after-teardown.ts"],
|
||||
globalTeardown: "<rootDir>/src/__tests__/teardown.ts",
|
||||
};
|
||||
|
||||
// To avoid the "Cannot use import statement outside a module" errors while transforming ESM.
|
||||
@@ -37,4 +39,8 @@ const config = {
|
||||
],
|
||||
};
|
||||
|
||||
process.env = Object.assign(process.env, {
|
||||
LANGFUSE_CACHE_API_KEY_ENABLED: "true",
|
||||
});
|
||||
|
||||
export default config;
|
||||
|
||||
+11
-9
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "2.65.0",
|
||||
"version": "2.71.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -14,9 +14,10 @@
|
||||
"prettier": "prettier --write ./src *.{ts,js}",
|
||||
"clean": "rm -rf node_modules",
|
||||
"start": "dotenv -e ../.env -- sh -c 'NEXT_MANUAL_SIG_HANDLE=true next start'",
|
||||
"test": "dotenv -e ../.env -- jest --runInBand",
|
||||
"test": "dotenv -e ../.env -- jest --runInBand --detectOpenHandles --verbose --testPathIgnorePatterns='__e2e__'",
|
||||
"test:watch": "dotenv -e ../.env -- jest --watch --runInBand",
|
||||
"test:e2e": "dotenv -e ../.env -- playwright test",
|
||||
"test:e2e:server": "dotenv -e ../.env -- jest --runInBand --detectOpenHandles --verbose --testPathPattern='__e2e__'",
|
||||
"models:migrate": "dotenv -e ../.env -- tsx scripts/model-match.ts",
|
||||
"generationCost:backfill": "dotenv -e ../.env -- tsx scripts/observations-backfill-calculated-cost.ts"
|
||||
},
|
||||
@@ -60,10 +61,9 @@
|
||||
"@remixicon/react": "^4.2.0",
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@sentry/nextjs": "^8.13.0",
|
||||
"@sentry/node": "^8.13.0",
|
||||
"@sentry/profiling-node": "^8.13.0",
|
||||
"@sentry/types": "^8.13.0",
|
||||
"@sentry/nextjs": "8.13.0",
|
||||
"@sentry/node": "8.13.0",
|
||||
"@sentry/profiling-node": "8.13.0",
|
||||
"@t3-oss/env-nextjs": "^0.10.1",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
@@ -78,6 +78,7 @@
|
||||
"@uiw/react-codemirror": "^4.21.25",
|
||||
"ai": "^3.0.23",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bullmq": "^5.4.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
@@ -86,8 +87,8 @@
|
||||
"date-fns": "^3.3.1",
|
||||
"decimal.js": "^10.4.3",
|
||||
"dompurify": "^3.1.5",
|
||||
"exponential-backoff": "^3.1.1",
|
||||
"graphql": "^16.9.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"ip-address": "^9.0.5",
|
||||
"js-tiktoken": "^1.0.12",
|
||||
"kysely": "^0.27.3",
|
||||
@@ -98,7 +99,7 @@
|
||||
"next-auth": "^4.24.7",
|
||||
"next-query-params": "^5.0.0",
|
||||
"next-themes": "^0.3.0",
|
||||
"posthog-js": "^1.122.0",
|
||||
"posthog-js": "^1.154.6",
|
||||
"posthog-node": "^3.6.3",
|
||||
"prexit": "^2.2.0",
|
||||
"prisma": "^5.13.0",
|
||||
@@ -156,7 +157,8 @@
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.7.2",
|
||||
"typescript": "^5.4.5"
|
||||
"typescript": "^5.4.5",
|
||||
"wait-for-expect": "^3.0.2"
|
||||
},
|
||||
"ct3aMetadata": {
|
||||
"initVersion": "7.13.0"
|
||||
|
||||
@@ -1007,6 +1007,13 @@ paths:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
- name: version
|
||||
in: query
|
||||
description: Optional filter to only include observations with a certain version.
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
nullable: true
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
@@ -1181,6 +1188,26 @@ paths:
|
||||
schema:
|
||||
type: integer
|
||||
nullable: true
|
||||
- name: fromUpdatedAt
|
||||
in: query
|
||||
description: >-
|
||||
Optional filter to only include prompt versions created/updated on
|
||||
or after a certain datetime (ISO 8601)
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
- name: toUpdatedAt
|
||||
in: query
|
||||
description: >-
|
||||
Optional filter to only include prompt versions created/updated
|
||||
before a certain datetime (ISO 8601)
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
@@ -1215,7 +1242,7 @@ paths:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
post:
|
||||
description: Create a prompt
|
||||
description: Create a new version for the prompt with the given `name`
|
||||
operationId: prompts_create
|
||||
tags:
|
||||
- Prompts
|
||||
@@ -1675,7 +1702,7 @@ paths:
|
||||
security: *ref_0
|
||||
/api/public/sessions:
|
||||
get:
|
||||
description: Get sessions.
|
||||
description: Get sessions
|
||||
operationId: sessions_list
|
||||
tags:
|
||||
- Sessions
|
||||
@@ -1846,7 +1873,7 @@ paths:
|
||||
security: *ref_0
|
||||
/api/public/traces:
|
||||
get:
|
||||
description: Get list of traces.
|
||||
description: Get list of traces
|
||||
operationId: trace_list
|
||||
tags:
|
||||
- Trace
|
||||
@@ -1924,6 +1951,20 @@ paths:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
- name: version
|
||||
in: query
|
||||
description: Optional filter to only include traces with a certain version.
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
nullable: true
|
||||
- name: release
|
||||
in: query
|
||||
description: Optional filter to only include traces with a certain release.
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
nullable: true
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
@@ -1969,32 +2010,44 @@ components:
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
description: The timestamp when the trace was created
|
||||
name:
|
||||
type: string
|
||||
nullable: true
|
||||
description: The name of the trace
|
||||
input:
|
||||
nullable: true
|
||||
description: The input data of the trace. Can be any JSON.
|
||||
output:
|
||||
nullable: true
|
||||
description: The output data of the trace. Can be any JSON.
|
||||
sessionId:
|
||||
type: string
|
||||
nullable: true
|
||||
description: The session identifier associated with the trace
|
||||
release:
|
||||
type: string
|
||||
nullable: true
|
||||
description: The release version of the application when the trace was created
|
||||
version:
|
||||
type: string
|
||||
nullable: true
|
||||
description: The version of the trace
|
||||
userId:
|
||||
type: string
|
||||
nullable: true
|
||||
description: The user identifier associated with the trace
|
||||
metadata:
|
||||
nullable: true
|
||||
description: The metadata associated with the trace. Can be any JSON.
|
||||
tags:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
description: >-
|
||||
The tags associated with the trace. Can be an array of strings or
|
||||
null.
|
||||
public:
|
||||
type: boolean
|
||||
nullable: true
|
||||
@@ -2101,56 +2154,74 @@ components:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: The unique identifier of the observation
|
||||
traceId:
|
||||
type: string
|
||||
nullable: true
|
||||
description: The trace ID associated with the observation
|
||||
type:
|
||||
type: string
|
||||
description: The type of the observation
|
||||
name:
|
||||
type: string
|
||||
nullable: true
|
||||
description: The name of the observation
|
||||
startTime:
|
||||
type: string
|
||||
format: date-time
|
||||
description: The start time of the observation
|
||||
endTime:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: The end time of the observation.
|
||||
completionStartTime:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: The completion start time of the observation
|
||||
model:
|
||||
type: string
|
||||
nullable: true
|
||||
description: The model used for the observation
|
||||
modelParameters:
|
||||
type: object
|
||||
additionalProperties:
|
||||
$ref: '#/components/schemas/MapValue'
|
||||
nullable: true
|
||||
description: The parameters of the model used for the observation
|
||||
input:
|
||||
nullable: true
|
||||
description: The input data of the observation
|
||||
version:
|
||||
type: string
|
||||
nullable: true
|
||||
description: The version of the observation
|
||||
metadata:
|
||||
nullable: true
|
||||
description: Additional metadata of the observation
|
||||
output:
|
||||
nullable: true
|
||||
description: The output data of the observation
|
||||
usage:
|
||||
$ref: '#/components/schemas/Usage'
|
||||
nullable: true
|
||||
description: The usage data of the observation
|
||||
level:
|
||||
$ref: '#/components/schemas/ObservationLevel'
|
||||
description: The level of the observation
|
||||
statusMessage:
|
||||
type: string
|
||||
nullable: true
|
||||
description: The status message of the observation
|
||||
parentObservationId:
|
||||
type: string
|
||||
nullable: true
|
||||
description: The parent observation ID
|
||||
promptId:
|
||||
type: string
|
||||
nullable: true
|
||||
description: The prompt ID associated with the observation
|
||||
required:
|
||||
- id
|
||||
- type
|
||||
@@ -2160,41 +2231,58 @@ components:
|
||||
title: ObservationsView
|
||||
type: object
|
||||
properties:
|
||||
promptName:
|
||||
type: string
|
||||
nullable: true
|
||||
description: The name of the prompt associated with the observation
|
||||
promptVersion:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: The version of the prompt associated with the observation
|
||||
modelId:
|
||||
type: string
|
||||
nullable: true
|
||||
description: The unique identifier of the model
|
||||
inputPrice:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
description: The price of the input in USD
|
||||
outputPrice:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
description: The price of the output in USD.
|
||||
totalPrice:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
description: The total price in USD.
|
||||
calculatedInputCost:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
description: The calculated cost of the input in USD
|
||||
calculatedOutputCost:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
description: The calculated cost of the output in USD
|
||||
calculatedTotalCost:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
description: The calculated total cost in USD
|
||||
latency:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
description: The latency in seconds.
|
||||
timeToFirstToken:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
description: The time to the first token in seconds
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Observation'
|
||||
Usage:
|
||||
@@ -2540,23 +2628,31 @@ components:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: Unique identifier of the dataset run
|
||||
name:
|
||||
type: string
|
||||
description: Name of the dataset run
|
||||
description:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Description of the run
|
||||
metadata:
|
||||
nullable: true
|
||||
description: Metadata of the dataset run
|
||||
datasetId:
|
||||
type: string
|
||||
description: Id of the associated dataset
|
||||
datasetName:
|
||||
type: string
|
||||
description: Name of the associated dataset
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: The date and time when the dataset run was created
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: The date and time when the dataset run was last updated
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
@@ -2715,8 +2811,8 @@ components:
|
||||
type: string
|
||||
nullable: true
|
||||
description: >-
|
||||
Dataset items are upserted on their id. Id needs to be globally
|
||||
unique and cannot be reused across datasets.
|
||||
Dataset items are upserted on their id. Id needs to be unique
|
||||
(project-level) and cannot be reused across datasets.
|
||||
status:
|
||||
$ref: '#/components/schemas/DatasetStatus'
|
||||
nullable: true
|
||||
@@ -3611,11 +3707,20 @@ components:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
lastUpdatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
lastConfig:
|
||||
description: >-
|
||||
Config object of the most recent prompt version that matches the
|
||||
filters (if any are provided)
|
||||
required:
|
||||
- name
|
||||
- versions
|
||||
- labels
|
||||
- tags
|
||||
- lastUpdatedAt
|
||||
- lastConfig
|
||||
CreatePromptRequest:
|
||||
title: CreatePromptRequest
|
||||
oneOf:
|
||||
|
||||
@@ -718,7 +718,7 @@
|
||||
"request": {
|
||||
"description": "Get a list of observations",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/observations?page=&limit=&name=&userId=&type=&traceId=&parentObservationId=&fromStartTime=&toStartTime=",
|
||||
"raw": "{{baseUrl}}/api/public/observations?page=&limit=&name=&userId=&type=&traceId=&parentObservationId=&fromStartTime=&toStartTime=&version=",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
@@ -772,6 +772,11 @@
|
||||
"key": "toStartTime",
|
||||
"value": "",
|
||||
"description": "Retrieve only observations with a start_time before this datetime (ISO 8601)."
|
||||
},
|
||||
{
|
||||
"key": "version",
|
||||
"value": "",
|
||||
"description": "Optional filter to only include observations with a certain version."
|
||||
}
|
||||
],
|
||||
"variable": []
|
||||
@@ -872,7 +877,7 @@
|
||||
"request": {
|
||||
"description": "Get a list of prompt names with versions and labels",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/v2/prompts?name=&label=&tag=&page=&limit=",
|
||||
"raw": "{{baseUrl}}/api/public/v2/prompts?name=&label=&tag=&page=&limit=&fromUpdatedAt=&toUpdatedAt=",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
@@ -907,6 +912,16 @@
|
||||
"key": "limit",
|
||||
"value": "",
|
||||
"description": "limit of items per page"
|
||||
},
|
||||
{
|
||||
"key": "fromUpdatedAt",
|
||||
"value": "",
|
||||
"description": "Optional filter to only include prompt versions created/updated on or after a certain datetime (ISO 8601)"
|
||||
},
|
||||
{
|
||||
"key": "toUpdatedAt",
|
||||
"value": "",
|
||||
"description": "Optional filter to only include prompt versions created/updated before a certain datetime (ISO 8601)"
|
||||
}
|
||||
],
|
||||
"variable": []
|
||||
@@ -922,7 +937,7 @@
|
||||
"_type": "endpoint",
|
||||
"name": "Create",
|
||||
"request": {
|
||||
"description": "Create a prompt",
|
||||
"description": "Create a new version for the prompt with the given `name`",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/v2/prompts",
|
||||
"host": [
|
||||
@@ -1261,7 +1276,7 @@
|
||||
"_type": "endpoint",
|
||||
"name": "List",
|
||||
"request": {
|
||||
"description": "Get sessions.",
|
||||
"description": "Get sessions",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/sessions?page=&limit=&fromTimestamp=&toTimestamp=",
|
||||
"host": [
|
||||
@@ -1378,9 +1393,9 @@
|
||||
"_type": "endpoint",
|
||||
"name": "List",
|
||||
"request": {
|
||||
"description": "Get list of traces.",
|
||||
"description": "Get list of traces",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/traces?page=&limit=&userId=&name=&sessionId=&fromTimestamp=&toTimestamp=&orderBy=&tags=",
|
||||
"raw": "{{baseUrl}}/api/public/traces?page=&limit=&userId=&name=&sessionId=&fromTimestamp=&toTimestamp=&orderBy=&tags=&version=&release=",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
@@ -1434,6 +1449,16 @@
|
||||
"key": "tags",
|
||||
"value": "",
|
||||
"description": "Only traces that include all of these tags will be returned."
|
||||
},
|
||||
{
|
||||
"key": "version",
|
||||
"value": "",
|
||||
"description": "Optional filter to only include traces with a certain version."
|
||||
},
|
||||
{
|
||||
"key": "release",
|
||||
"value": "",
|
||||
"description": "Optional filter to only include traces with a certain release."
|
||||
}
|
||||
],
|
||||
"variable": []
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 140"><g fill-rule="nonzero" fill="none"><path d="M55.383 75.225c-1.874 3.748-7.222 3.748-9.096 0l-4.482-8.963a5.085 5.085 0 0 1 0-4.548l4.482-8.964c1.874-3.748 7.222-3.748 9.096 0l4.482 8.964a5.084 5.084 0 0 1 0 4.548l-4.482 8.963Zm0 50.836c-1.874 3.747-7.222 3.747-9.096 0l-4.482-8.964a5.085 5.085 0 0 1 0-4.548l4.482-8.964c1.874-3.748 7.222-3.748 9.096 0l4.482 8.964a5.084 5.084 0 0 1 0 4.548l-4.482 8.964Z" fill="#1D4AFF"/><path d="M0 106.765c0-4.53 5.477-6.8 8.68-3.596l23.307 23.307c3.204 3.204.935 8.68-3.595 8.68H5.085A5.085 5.085 0 0 1 0 130.073v-23.307Zm0-24.55c0 1.35.536 2.643 1.49 3.596l47.856 47.856a5.086 5.086 0 0 0 3.595 1.49h26.286c4.53 0 6.799-5.477 3.595-8.681L8.681 52.334C5.477 49.131 0 51.4 0 55.93v26.286Zm0-50.834c0 1.348.536 2.642 1.49 3.595l98.69 98.691a5.086 5.086 0 0 0 3.596 1.49h26.285c4.53 0 6.8-5.477 3.596-8.681L8.681 1.5C5.477-1.704 0 .565 0 5.095v26.286Zm50.835 0c0 1.348.536 2.642 1.49 3.595l91.5 91.5c3.203 3.204 8.68.935 8.68-3.596V96.595a5.086 5.086 0 0 0-1.49-3.596l-91.5-91.5c-3.203-3.203-8.68-.934-8.68 3.596v26.286ZM110.35 1.5c-3.203-3.204-8.68-.935-8.68 3.595v26.286c0 1.348.536 2.642 1.49 3.595l40.664 40.665c3.204 3.204 8.68.935 8.68-3.596V45.76a5.086 5.086 0 0 0-1.489-3.596L110.35 1.5Z" fill="#F9BD2B"/><path d="m216.24 107.388-47.864-47.863c-3.204-3.204-8.681-.935-8.681 3.595v66.952a5.085 5.085 0 0 0 5.085 5.085h74.142a5.085 5.085 0 0 0 5.085-5.085v-6.097c0-2.809-2.286-5.052-5.07-5.414a39.27 39.27 0 0 1-22.698-11.173Zm-32.145 11.502a8.137 8.137 0 0 1-8.133-8.134 8.137 8.137 0 0 1 8.133-8.134 8.137 8.137 0 0 1 8.134 8.134 8.137 8.137 0 0 1-8.134 8.134Z" fill="#000"/><path d="M0 130.072a5.085 5.085 0 0 0 5.085 5.085h23.307c4.53 0 6.799-5.477 3.595-8.681L8.681 103.169C5.477 99.966 0 102.235 0 106.765v23.307Zm50.835-86.418L8.68 1.5C5.477-1.704 0 .565 0 5.095v26.286c0 1.348.536 2.642 1.49 3.595l49.345 49.346V43.654ZM8.68 52.334C5.477 49.131 0 51.4 0 55.93v26.286c0 1.348.536 2.642 1.49 3.595l49.345 49.346V94.489L8.68 52.334Z" fill="#1D4AFF"/><path d="M101.67 45.76a5.083 5.083 0 0 0-1.49-3.596L59.516 1.5c-3.204-3.204-8.681-.935-8.681 3.595v26.286c0 1.348.536 2.642 1.49 3.595l49.345 49.346V45.76Zm-50.835 89.397h28.392c4.53 0 6.799-5.477 3.595-8.681L50.835 94.489v40.668Zm0-91.503v38.562c0 1.348.536 2.642 1.49 3.595l49.345 49.346V96.595a5.084 5.084 0 0 0-1.49-3.596L50.835 43.654Z" fill="#F54E00"/><path d="M303.32 114.86h20.888V80.22h17.452c19.17 0 31.466-11.37 31.466-28.954 0-17.584-12.295-28.954-31.466-28.954h-38.34v92.547Zm20.888-52.488V40.16h15.337c7.932 0 12.692 4.23 12.692 11.105 0 6.876-4.76 11.106-12.692 11.106h-15.337Zm86.71 53.545c20.36 0 35.167-14.543 35.167-34.375 0-19.831-14.807-34.374-35.167-34.374-20.625 0-35.168 14.543-35.168 34.374 0 19.832 14.543 34.375 35.168 34.375Zm-15.866-34.375c0-10.577 6.346-17.848 15.866-17.848 9.386 0 15.733 7.271 15.733 17.848 0 10.577-6.347 17.849-15.733 17.849-9.52 0-15.866-7.272-15.866-17.849Zm84.462 34.375c15.601 0 26.178-9.784 26.178-21.286 0-26.97-35.829-18.245-35.829-28.822 0-2.908 3.04-4.759 7.404-4.759 4.495 0 9.916 2.776 11.634 8.858l15.601-6.479c-3.04-9.65-14.279-16.261-27.896-16.261-14.676 0-23.798 8.725-23.798 19.17 0 25.252 35.3 18.245 35.3 28.69 0 3.702-3.437 6.214-8.594 6.214-7.403 0-12.56-5.156-14.146-11.37l-15.601 6.081c3.438 10.048 13.486 19.964 29.747 19.964Zm76.43-1.718-1.321-16.791c-2.248 1.19-5.157 1.586-7.536 1.586-4.76 0-7.933-3.437-7.933-9.387V64.355h16.13v-16.13h-16.13V28.923h-19.435v19.302h-10.577v16.13h10.577v27.764c0 16.13 10.974 23.798 25.384 23.798 3.967 0 7.669-.66 10.842-1.718Zm67.764-91.887v35.961h-36.755v-35.96h-20.89v92.546h20.89V76.122h36.755v38.737h21.021V22.312h-21.021Zm67.386 93.605c20.36 0 35.168-14.543 35.168-34.375 0-19.831-14.807-34.374-35.168-34.374-20.625 0-35.168 14.543-35.168 34.374 0 19.832 14.543 34.375 35.168 34.375ZM675.23 81.542c0-10.577 6.346-17.848 15.865-17.848 9.387 0 15.733 7.271 15.733 17.848 0 10.577-6.346 17.849-15.733 17.849-9.519 0-15.865-7.272-15.865-17.849Zm88.545 31.202c7.272 0 13.75-2.512 17.188-6.875v6.346c0 7.404-5.95 12.56-15.072 12.56-6.479 0-12.164-3.173-13.09-8.594l-17.715 2.777c2.38 12.56 15.204 21.022 30.805 21.022 20.492 0 34.11-12.032 34.11-29.88V48.225h-19.17v5.685c-3.57-4.098-9.652-6.742-17.452-6.742-18.51 0-30.144 12.692-30.144 32.788 0 20.096 11.634 32.788 30.54 32.788ZM752.14 79.956c0-9.916 5.817-16.262 14.807-16.262 9.123 0 14.94 6.346 14.94 16.262s-5.817 16.262-14.94 16.262c-8.99 0-14.807-6.346-14.807-16.262Z" fill="#000"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 4.4 KiB |
@@ -5,11 +5,11 @@
|
||||
|
||||
import "dotenv/config";
|
||||
|
||||
import { findModel } from "@/src/server/api/services/EventProcessor";
|
||||
import { findModel } from "@langfuse/shared/src/server";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import lodash from "lodash";
|
||||
import { tokenCount } from "@/src/features/ingest/lib/usage";
|
||||
import { type Prisma } from "@langfuse/shared/src/db";
|
||||
import { tokenCount } from "@/src/features/ingest/usage";
|
||||
|
||||
async function main() {
|
||||
return await modelMatch();
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { v4 } from "uuid";
|
||||
import { JobExecutionStatus, prisma } from "@langfuse/shared/src/db";
|
||||
import { redis } from "@langfuse/shared/src/server";
|
||||
import waitForExpect from "wait-for-expect";
|
||||
import { ApiKeyZod } from "@/src/features/public-api/server/apiAuth";
|
||||
|
||||
const generateAuth = (username: string, password: string) => {
|
||||
const auth = Buffer.from(`${username}:${password}`).toString("base64");
|
||||
return `Basic ${auth}`;
|
||||
};
|
||||
|
||||
const workerAdminAuth = generateAuth("admin", "myworkerpassword");
|
||||
|
||||
const userApiKeyAuth = generateAuth("pk-lf-1234567890", "sk-lf-1234567890");
|
||||
|
||||
describe("Health endpoints", () => {
|
||||
it("web container returns healthy", async () => {
|
||||
// Arrange
|
||||
const url = "http://localhost:3000/api/public/health";
|
||||
|
||||
// Act
|
||||
const response = await fetch(url);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("worker container returns healthy", async () => {
|
||||
// Arrange
|
||||
const url = "http://localhost:3030/api/health";
|
||||
|
||||
// Act
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: workerAdminAuth,
|
||||
},
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ingestion Pipeline", () => {
|
||||
it("ingest a trace", async () => {
|
||||
const traceId = v4();
|
||||
const spanId = v4();
|
||||
|
||||
const event = {
|
||||
batch: [
|
||||
{
|
||||
id: v4(),
|
||||
type: "trace-create",
|
||||
timestamp: new Date().toISOString(),
|
||||
body: {
|
||||
name: "test trace",
|
||||
id: traceId,
|
||||
userId: "user-1", // triggers the eval
|
||||
},
|
||||
},
|
||||
{
|
||||
id: v4(),
|
||||
type: "span-create",
|
||||
timestamp: new Date().toISOString(),
|
||||
body: {
|
||||
id: spanId,
|
||||
traceId: traceId,
|
||||
name: "test span",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
// Arrange
|
||||
const url = "http://localhost:3000/api/public/ingestion";
|
||||
|
||||
// Act
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: userApiKeyAuth,
|
||||
},
|
||||
body: JSON.stringify(event),
|
||||
});
|
||||
|
||||
await waitForExpect(async () => {
|
||||
// we need a second call to the public API with the API key, so that it is stored in redis
|
||||
// first call (ingestion above) generates the new, fast API hash
|
||||
// second call (below) stores the API key in redis
|
||||
const traceUrl = `http://localhost:3000/api/public/traces/${traceId}`;
|
||||
|
||||
const traceResponse = await fetch(traceUrl, {
|
||||
headers: {
|
||||
Authorization: userApiKeyAuth,
|
||||
},
|
||||
});
|
||||
|
||||
expect(traceResponse.status).toBe(200);
|
||||
expect(traceResponse.body).not.toBeNull();
|
||||
expect((await traceResponse.json()).id).toBe(traceId);
|
||||
|
||||
const trace = await prisma.trace.findUnique({
|
||||
where: {
|
||||
id: traceId,
|
||||
},
|
||||
});
|
||||
expect(trace).not.toBeNull();
|
||||
expect(trace?.name).toBe("test trace");
|
||||
|
||||
const observation = await prisma.observation.findUnique({
|
||||
where: {
|
||||
id: spanId,
|
||||
traceId: traceId,
|
||||
},
|
||||
});
|
||||
expect(observation).not.toBeNull();
|
||||
expect(observation?.name).toBe("test span");
|
||||
|
||||
expect(redis).not.toBeNull();
|
||||
|
||||
const redisKeys = await redis?.keys(`api-key:*`);
|
||||
expect(redisKeys?.length).toBe(1);
|
||||
const redisValue = await redis?.get(redisKeys![0]);
|
||||
|
||||
const llmApiKey = ApiKeyZod.parse(JSON.parse(redisValue!));
|
||||
expect(llmApiKey.projectId).toBe("7a88fb47-b4e2-43b8-a06c-a5ce950dc53a");
|
||||
});
|
||||
|
||||
// check for eval
|
||||
await waitForExpect(async () => {
|
||||
const evalExecution = await prisma.jobExecution.findFirst({
|
||||
where: {
|
||||
jobInputTraceId: traceId,
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
},
|
||||
});
|
||||
|
||||
expect(evalExecution).not.toBeNull();
|
||||
|
||||
if (!evalExecution) {
|
||||
return;
|
||||
}
|
||||
|
||||
// failure due to missing openai key in the pipeline. Expected
|
||||
expect(evalExecution.status).toBe(JobExecutionStatus.ERROR);
|
||||
}, 20000);
|
||||
|
||||
expect(response.status).toBe(207);
|
||||
}, 25000);
|
||||
});
|
||||
|
||||
describe("Prompts endpoint", () => {
|
||||
it("creates and returns a prompt", async () => {
|
||||
const promptName = "prompt-name" + v4();
|
||||
const chatMessages = [
|
||||
{ role: "system", content: "You are a bot" },
|
||||
{ role: "user", content: "What's up?" },
|
||||
];
|
||||
const response = await fetch(
|
||||
"http://localhost:3000/api/public/v2/prompts",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: userApiKeyAuth,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: promptName,
|
||||
prompt: chatMessages,
|
||||
type: "chat",
|
||||
labels: ["production"],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
|
||||
const fetchedPrompt = await fetch(
|
||||
`http://localhost:3000/api/public/v2/prompts/${encodeURIComponent(promptName)}`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: userApiKeyAuth,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(fetchedPrompt.status).toBe(200);
|
||||
expect(fetchedPrompt.body).not.toBeNull();
|
||||
|
||||
if (fetchedPrompt.body === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validatedPrompt = await fetchedPrompt.json();
|
||||
|
||||
expect(validatedPrompt.name).toBe(promptName);
|
||||
|
||||
const redisKey = `prompt:7a88fb47-b4e2-43b8-a06c-a5ce950dc53a:${promptName}:${validatedPrompt.labels[0]}`;
|
||||
const redisValue = await redis?.get(redisKey);
|
||||
|
||||
expect(redisValue).not.toBeNull();
|
||||
if (!redisValue) {
|
||||
return;
|
||||
}
|
||||
expect(JSON.parse(redisValue)).toEqual(validatedPrompt);
|
||||
});
|
||||
});
|
||||
@@ -15,16 +15,7 @@ test("should redirect to home if signed in", async ({ page }) => {
|
||||
// wait 2 seconds
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
if (process.env.CI)
|
||||
await expect(page).toHaveURL(
|
||||
// project id from seed.ts
|
||||
"/project/7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
);
|
||||
else
|
||||
console.log(
|
||||
"Test skipped as redirect depends on db state, URL after signing in:",
|
||||
page.url(),
|
||||
);
|
||||
await expect(page).toHaveURL("/");
|
||||
});
|
||||
|
||||
test("Successfully sign up & able to go to homepage", async ({ page }) => {
|
||||
@@ -35,7 +26,7 @@ test("Successfully sign up & able to go to homepage", async ({ page }) => {
|
||||
await page.click('button[data-testid="submit-email-password-sign-up-form"]');
|
||||
await page.waitForTimeout(2000);
|
||||
// see get started page
|
||||
await expect(page).toHaveURL("/?getStarted=1");
|
||||
await expect(page).toHaveURL("/");
|
||||
});
|
||||
|
||||
test("Successfully sign up & able to go to homepage with uppercase email", async ({
|
||||
@@ -48,7 +39,7 @@ test("Successfully sign up & able to go to homepage with uppercase email", async
|
||||
await page.click('button[data-testid="submit-email-password-sign-up-form"]');
|
||||
await page.waitForTimeout(2000);
|
||||
// see get started page
|
||||
await expect(page).toHaveURL("/?getStarted=1");
|
||||
await expect(page).toHaveURL("/");
|
||||
});
|
||||
|
||||
test("Signup input validation", async ({ page }) => {
|
||||
@@ -64,7 +55,7 @@ test("Signup input validation", async ({ page }) => {
|
||||
await page.click('button[data-testid="submit-email-password-sign-up-form"]');
|
||||
await page.waitForTimeout(2000);
|
||||
// don't see get started page
|
||||
await expect(page).not.toHaveURL("/?getStarted=1");
|
||||
await expect(page).not.toHaveURL("/");
|
||||
});
|
||||
|
||||
// random email address to be used in tests
|
||||
@@ -83,7 +74,8 @@ test("Unauthenticated user should be redirected to target URL after login", asyn
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// project id and prompt from seed.ts
|
||||
const promptUrl = "/project/7a88fb47-b4e2-43b8-a06c-a5ce950dc53a/prompts/summary-prompt";
|
||||
const promptUrl =
|
||||
"/project/7a88fb47-b4e2-43b8-a06c-a5ce950dc53a/prompts/summary-prompt";
|
||||
|
||||
await page.getByRole("button", { name: /Demo User/ }).click();
|
||||
|
||||
|
||||
@@ -1,48 +1,59 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("should see new projects dialog open after clicking new project btn", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("auth/sign-up");
|
||||
await page.fill('input[name="name"]', "demo user");
|
||||
await page.fill('input[name="email"]', randomEmailAddress());
|
||||
await page.fill('input[type="password"]', "password");
|
||||
await page.click('button[data-testid="submit-email-password-sign-up-form"]');
|
||||
await page.waitForTimeout(2000);
|
||||
await page.waitForTimeout(2000);
|
||||
expect(await page.innerHTML("data-testid=create-new-project-title")).toBe(
|
||||
"Create new project",
|
||||
);
|
||||
await page.click('[data-testid="create-project-btn"]');
|
||||
await page.waitForTimeout(2000);
|
||||
await expect(page.locator("data-testid=new-project-form")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Create a project with provided name", async ({ page }) => {
|
||||
test("Sign in, create an organization, create a project", async ({ page }) => {
|
||||
test.setTimeout(60000);
|
||||
|
||||
// Sign in
|
||||
await page.goto("/auth/sign-in");
|
||||
await page.fill('input[name="email"]', "demo@langfuse.com");
|
||||
await page.fill('input[type="password"]', "password");
|
||||
await page.click('button[data-testid="submit-email-password-sign-in-form"]');
|
||||
await page.waitForTimeout(2000);
|
||||
await page.isVisible('[data-testid="new-project-form"]');
|
||||
await page.click('[data-testid="create-project-btn"]');
|
||||
await page.waitForTimeout(2000);
|
||||
await expect(page.locator("data-testid=new-project-form")).toBeVisible();
|
||||
await page.fill(
|
||||
'[data-testid="new-project-name-input"]',
|
||||
"my e2e demo project",
|
||||
);
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForTimeout(2000);
|
||||
expect(page.url()).toContain("/project/");
|
||||
await page.waitForTimeout(2000);
|
||||
expect(await page.getByTestId("project-name").textContent()).toContain(
|
||||
"my e2e demo project",
|
||||
);
|
||||
});
|
||||
await expect(page).toHaveURL("/");
|
||||
|
||||
// random email address to be used in tests
|
||||
const randomEmailAddress = () =>
|
||||
Math.random().toString(36).substring(2, 11) + "@example.com";
|
||||
// Start create org flow
|
||||
await page.isVisible('[data-testid="create-organization-btn"]');
|
||||
await page.click('[data-testid="create-organization-btn"]');
|
||||
await page.waitForTimeout(2000);
|
||||
await expect(page).toHaveURL("/setup");
|
||||
|
||||
// Create an organization
|
||||
await expect(page.locator("data-testid=new-org-form")).toBeVisible();
|
||||
await page.fill('[data-testid="new-org-name-input"]', "e2e test org");
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForTimeout(5000);
|
||||
expect(page.url()).toContain("/organization/");
|
||||
expect(page.url()).toContain("/setup?orgstep=invite-members");
|
||||
|
||||
// Parse the organization ID from the URL using a simpler method
|
||||
const url = new URL(page.url());
|
||||
const organizationId = url.pathname.split("/")[2];
|
||||
console.log("organization", organizationId);
|
||||
|
||||
// Skip add new members step
|
||||
await page.isVisible('[data-testid="btn-skip-add-members"]');
|
||||
await page.click('[data-testid="btn-skip-add-members"]');
|
||||
expect(page.url()).toContain(
|
||||
"/organization/" + organizationId + "/setup?orgstep=create-project",
|
||||
);
|
||||
|
||||
// Create project
|
||||
await expect(page.locator("data-testid=new-project-form")).toBeVisible();
|
||||
await page.fill('[data-testid="new-project-name-input"]', "e2e test project");
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForTimeout(5000);
|
||||
expect(page.url()).toContain("/project/");
|
||||
expect(page.url()).toContain("/setup");
|
||||
|
||||
const projectUrl = new URL(page.url());
|
||||
const projectId = projectUrl.pathname.split("/")[2];
|
||||
|
||||
// check that the project exists by navigating to its dashboard
|
||||
await page.goto("/project/" + projectId);
|
||||
await page.waitForTimeout(2000);
|
||||
expect(page.url()).toContain("/project/" + projectId);
|
||||
expect(page.url()).not.toContain("/setup");
|
||||
|
||||
const headings = await page.locator("h2").allTextContents();
|
||||
expect(headings).toContain("Dashboard");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import teardown from "@/src/__tests__/teardown";
|
||||
|
||||
afterAll(async () => {
|
||||
await teardown();
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { aggregateScores } from "@/src/features/scores/lib/aggregateScores";
|
||||
import { type APIScore } from "@langfuse/shared";
|
||||
|
||||
describe("aggregateScores", () => {
|
||||
it("should return an empty object for an empty array", () => {
|
||||
const scores: APIScore[] = [];
|
||||
expect(aggregateScores(scores)).toEqual({});
|
||||
});
|
||||
|
||||
it("should correctly aggregate a single numeric score", () => {
|
||||
const scores = [
|
||||
{
|
||||
name: "test",
|
||||
source: "API",
|
||||
dataType: "NUMERIC",
|
||||
value: 5,
|
||||
comment: "test comment",
|
||||
},
|
||||
] as APIScore[];
|
||||
expect(aggregateScores(scores)).toEqual({
|
||||
"test-API-NUMERIC": {
|
||||
type: "NUMERIC",
|
||||
values: [5],
|
||||
average: 5,
|
||||
comment: "test comment",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should correctly aggregate multiple numeric scores with the same key", () => {
|
||||
const scores = [
|
||||
{
|
||||
name: "test",
|
||||
source: "API",
|
||||
dataType: "NUMERIC",
|
||||
value: 5,
|
||||
comment: "test comment",
|
||||
},
|
||||
{
|
||||
name: "test",
|
||||
source: "API",
|
||||
dataType: "NUMERIC",
|
||||
value: 7,
|
||||
comment: "another comment",
|
||||
},
|
||||
] as APIScore[];
|
||||
expect(aggregateScores(scores)).toEqual({
|
||||
"test-API-NUMERIC": {
|
||||
type: "NUMERIC",
|
||||
values: [5, 7],
|
||||
average: 6,
|
||||
comment: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should correctly aggregate scores with different keys", () => {
|
||||
const scores: APIScore[] = [
|
||||
{
|
||||
name: "test1",
|
||||
source: "API",
|
||||
dataType: "NUMERIC",
|
||||
value: 5,
|
||||
comment: "test comment",
|
||||
},
|
||||
{
|
||||
name: "test1",
|
||||
source: "ANNOTATION",
|
||||
dataType: "NUMERIC",
|
||||
value: 7,
|
||||
comment: "another comment",
|
||||
},
|
||||
] as APIScore[];
|
||||
expect(aggregateScores(scores)).toEqual({
|
||||
"test1-API-NUMERIC": {
|
||||
type: "NUMERIC",
|
||||
values: [5],
|
||||
average: 5,
|
||||
comment: "test comment",
|
||||
},
|
||||
"test1-ANNOTATION-NUMERIC": {
|
||||
type: "NUMERIC",
|
||||
values: [7],
|
||||
average: 7,
|
||||
comment: "another comment",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should correctly aggregate a single Categorical score", () => {
|
||||
const scores = [
|
||||
{
|
||||
name: "test",
|
||||
source: "ANNOTATION",
|
||||
dataType: "CATEGORICAL",
|
||||
stringValue: "good",
|
||||
comment: "test comment",
|
||||
},
|
||||
] as APIScore[];
|
||||
expect(aggregateScores(scores)).toEqual({
|
||||
"test-ANNOTATION-CATEGORICAL": {
|
||||
type: "CATEGORICAL",
|
||||
values: ["good"],
|
||||
valueCounts: [{ value: "good", count: 1 }],
|
||||
comment: "test comment",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should correctly aggregate multiple Categorical scores with the same key", () => {
|
||||
const scores = [
|
||||
{
|
||||
name: "test",
|
||||
source: "API",
|
||||
dataType: "BOOLEAN",
|
||||
stringValue: "True",
|
||||
comment: "test comment",
|
||||
},
|
||||
{
|
||||
name: "test",
|
||||
source: "API",
|
||||
dataType: "BOOLEAN",
|
||||
stringValue: "False",
|
||||
comment: "another comment",
|
||||
},
|
||||
] as APIScore[];
|
||||
expect(aggregateScores(scores)).toEqual({
|
||||
"test-API-BOOLEAN": {
|
||||
type: "CATEGORICAL",
|
||||
values: ["True", "False"],
|
||||
valueCounts: [
|
||||
{ value: "True", count: 1 },
|
||||
{ value: "False", count: 1 },
|
||||
],
|
||||
comment: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should correctly aggregate scores with mixed types and the same name", () => {
|
||||
const scores = [
|
||||
{
|
||||
name: "test",
|
||||
source: "API",
|
||||
dataType: "NUMERIC",
|
||||
value: 5,
|
||||
comment: "test comment",
|
||||
},
|
||||
{
|
||||
name: "test",
|
||||
source: "ANNOTATION",
|
||||
dataType: "CATEGORICAL",
|
||||
stringValue: "good",
|
||||
comment: "another comment",
|
||||
},
|
||||
{
|
||||
name: "test",
|
||||
source: "ANNOTATION",
|
||||
dataType: "CATEGORICAL",
|
||||
stringValue: "bad",
|
||||
comment: "last comment",
|
||||
},
|
||||
{
|
||||
name: "test",
|
||||
source: "ANNOTATION",
|
||||
dataType: "CATEGORICAL",
|
||||
stringValue: "good",
|
||||
comment: "last comment",
|
||||
},
|
||||
] as APIScore[];
|
||||
expect(aggregateScores(scores)).toEqual({
|
||||
"test-API-NUMERIC": {
|
||||
type: "NUMERIC",
|
||||
values: [5],
|
||||
average: 5,
|
||||
comment: "test comment",
|
||||
},
|
||||
"test-ANNOTATION-CATEGORICAL": {
|
||||
type: "CATEGORICAL",
|
||||
values: ["good", "bad", "good"],
|
||||
valueCounts: [
|
||||
{ value: "good", count: 2 },
|
||||
{ value: "bad", count: 1 },
|
||||
],
|
||||
comment: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,87 +2,398 @@ import {
|
||||
getDisplaySecretKey,
|
||||
hashSecretKey,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { verifyAuthHeaderAndReturnScope } from "@/src/features/public-api/server/apiAuth";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import {
|
||||
ApiAuthService,
|
||||
ApiKeyZod,
|
||||
} from "@/src/features/public-api/server/apiAuth";
|
||||
import { type PrismaClient, prisma } from "@langfuse/shared/src/db";
|
||||
import { Redis } from "ioredis";
|
||||
import { env } from "@/src/env.mjs";
|
||||
|
||||
describe("Validate api calls", () => {
|
||||
describe("Authenticate API calls", () => {
|
||||
beforeEach(async () => {
|
||||
await prisma.score.deleteMany();
|
||||
await prisma.observation.deleteMany();
|
||||
await prisma.trace.deleteMany();
|
||||
await prisma.apiKey.deleteMany();
|
||||
});
|
||||
|
||||
it("should create new api key", async () => {
|
||||
await createAPIKey();
|
||||
const auth = await verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
expect(auth.validKey).toBe(true);
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
});
|
||||
expect(apiKey).not.toBeNull();
|
||||
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
|
||||
afterEach(async () => {
|
||||
await prisma.score.deleteMany();
|
||||
await prisma.observation.deleteMany();
|
||||
await prisma.trace.deleteMany();
|
||||
await prisma.apiKey.deleteMany();
|
||||
});
|
||||
|
||||
it("should create new api key and succeed with new key", async () => {
|
||||
await createAPIKey();
|
||||
const auth = await verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
expect(auth.validKey).toBe(true);
|
||||
describe("validates without redis", () => {
|
||||
it("should create new api key", async () => {
|
||||
await createAPIKey();
|
||||
const auth = await new ApiAuthService(
|
||||
prisma,
|
||||
null,
|
||||
).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
expect(auth.validKey).toBe(true);
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
});
|
||||
expect(apiKey).not.toBeNull();
|
||||
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
|
||||
});
|
||||
expect(apiKey).not.toBeNull();
|
||||
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
|
||||
|
||||
const auth2 = await verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
expect(auth2.validKey).toBe(true);
|
||||
it("should create new api key and succeed with new key", async () => {
|
||||
await createAPIKey();
|
||||
const auth = await new ApiAuthService(
|
||||
prisma,
|
||||
null,
|
||||
).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
expect(auth.validKey).toBe(true);
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
});
|
||||
expect(apiKey).not.toBeNull();
|
||||
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
|
||||
|
||||
const auth2 = await new ApiAuthService(
|
||||
prisma,
|
||||
null,
|
||||
).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
expect(auth2.validKey).toBe(true);
|
||||
});
|
||||
|
||||
it("should fail on wrong api key with new key", async () => {
|
||||
await createAPIKey();
|
||||
const auth = await new ApiAuthService(
|
||||
prisma,
|
||||
null,
|
||||
).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
expect(auth.validKey).toBe(true);
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
});
|
||||
|
||||
expect(apiKey).not.toBeNull();
|
||||
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
|
||||
|
||||
const wrongAuth = await new ApiAuthService(
|
||||
prisma,
|
||||
null,
|
||||
).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkx",
|
||||
);
|
||||
expect(wrongAuth.validKey).toBe(false);
|
||||
});
|
||||
|
||||
it("should fail on wrong api key without new key", async () => {
|
||||
await createAPIKey();
|
||||
const initialApiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
});
|
||||
expect(initialApiKey).not.toBeNull();
|
||||
expect(initialApiKey?.fastHashedSecretKey).toBeNull();
|
||||
|
||||
const auth = await new ApiAuthService(
|
||||
prisma,
|
||||
null,
|
||||
).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkx",
|
||||
);
|
||||
expect(auth.validKey).toBe(false);
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
});
|
||||
expect(apiKey).not.toBeNull();
|
||||
expect(apiKey?.fastHashedSecretKey).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail on wrong api key with new key", async () => {
|
||||
await createAPIKey();
|
||||
const auth = await verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
expect(auth.validKey).toBe(true);
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
describe("validates with redis", () => {
|
||||
const redis = new Redis("redis://:myredissecret@127.0.0.1:6379", {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
expect(apiKey).not.toBeNull();
|
||||
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
|
||||
|
||||
const wrongAuth = await verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkx",
|
||||
);
|
||||
expect(wrongAuth.validKey).toBe(false);
|
||||
});
|
||||
beforeEach(async () => {
|
||||
// if we do not remove the key, it will remain in the cache and
|
||||
// calling the test twice will not add the key to the cache
|
||||
|
||||
it("should fail on wrong api key without new key", async () => {
|
||||
await createAPIKey();
|
||||
const initialApiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
const keys = await redis.keys("api-key*");
|
||||
if (keys.length > 0) {
|
||||
await redis.del(keys);
|
||||
}
|
||||
});
|
||||
expect(initialApiKey).not.toBeNull();
|
||||
expect(initialApiKey?.fastHashedSecretKey).toBeNull();
|
||||
|
||||
const auth = await verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkx",
|
||||
);
|
||||
expect(auth.validKey).toBe(false);
|
||||
afterEach(async () => {
|
||||
// if we do not remove the key, it will remain in the cache and
|
||||
// calling the test twice will not add the key to the cache
|
||||
const keys = await redis.keys("api-key*");
|
||||
if (keys.length > 0) {
|
||||
await redis.del(keys);
|
||||
}
|
||||
});
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
afterAll(async () => {
|
||||
redis.disconnect();
|
||||
});
|
||||
|
||||
it("should create new api key and read from cache", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
// first auth will generate the fast hashed api key
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
});
|
||||
|
||||
expect(apiKey).not.toBeNull();
|
||||
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
|
||||
|
||||
const cachedKey = await redis.get(
|
||||
`api-key:${apiKey?.fastHashedSecretKey}`,
|
||||
);
|
||||
expect(cachedKey).toBeNull();
|
||||
|
||||
// second will add the key to redis
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
const cachedKey2 = await redis.get(
|
||||
`api-key:${apiKey?.fastHashedSecretKey}`,
|
||||
);
|
||||
|
||||
expect(cachedKey2).not.toBeNull();
|
||||
|
||||
const parsed = ApiKeyZod.parse(JSON.parse(cachedKey2!));
|
||||
|
||||
expect(parsed).toEqual({
|
||||
...apiKey,
|
||||
createdAt: apiKey?.createdAt.toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
it("searching for non-existing key stores flag in redis and fails auth", async () => {
|
||||
// key does not exist in database
|
||||
|
||||
const verification = await new ApiAuthService(
|
||||
prisma,
|
||||
redis,
|
||||
).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
expect(verification.validKey).toBe(false);
|
||||
|
||||
const redisKeys = await redis.keys(`api-key:*`);
|
||||
expect(redisKeys.length).toBe(1);
|
||||
const redisValue = await redis.get(redisKeys[0]);
|
||||
expect(redisValue).toBe('"api-key-non-existent"');
|
||||
});
|
||||
|
||||
it("searching for non-existing key again fails auth", async () => {
|
||||
// key does not exist in database
|
||||
|
||||
const verification = await new ApiAuthService(
|
||||
prisma,
|
||||
redis,
|
||||
).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
expect(verification.validKey).toBe(false);
|
||||
|
||||
const redisKeys = await redis.keys(`api-key:*`);
|
||||
expect(redisKeys.length).toBe(1);
|
||||
const redisValue = await redis.get(redisKeys[0]);
|
||||
expect(redisValue).toBe('"api-key-non-existent"');
|
||||
|
||||
const verification2 = await new ApiAuthService(
|
||||
prisma,
|
||||
redis,
|
||||
).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
expect(verification2.validKey).toBe(false);
|
||||
|
||||
const redisKeys2 = await redis.keys(`api-key:*`);
|
||||
expect(redisKeys2.length).toBe(1);
|
||||
const redisValue2 = await redis.get(redisKeys[0]);
|
||||
expect(redisValue2).toBe('"api-key-non-existent"');
|
||||
});
|
||||
|
||||
it("prisma should not be used when reading cached keys", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
// Mock prisma
|
||||
const mockPrisma = {
|
||||
apiKey: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
// first auth will generate the fast hashed api key
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
// second will add the key to redis
|
||||
const auth2 = await new ApiAuthService(
|
||||
prisma,
|
||||
redis,
|
||||
).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
// third will read from redis only
|
||||
await new ApiAuthService(
|
||||
mockPrisma as unknown as PrismaClient,
|
||||
redis,
|
||||
).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
expect(auth2.validKey).toBe(true);
|
||||
|
||||
// Ensure prisma was not called
|
||||
expect(mockPrisma.apiKey.findUnique).not.toHaveBeenCalled();
|
||||
|
||||
const cachedKey = await redis.get(
|
||||
"api-key:ed6818ada09bdad405a74ac72773dde1708dd3fc6fe8bb81b59927400419d227",
|
||||
);
|
||||
expect(cachedKey).not.toBeNull();
|
||||
|
||||
const parsed = ApiKeyZod.parse(JSON.parse(cachedKey!));
|
||||
|
||||
expect(parsed).toEqual({
|
||||
id: expect.any(String),
|
||||
note: "seeded key",
|
||||
publicKey: "pk-lf-1234567890",
|
||||
hashedSecretKey: expect.any(String),
|
||||
fastHashedSecretKey: expect.any(String),
|
||||
displaySecretKey: expect.any(String),
|
||||
createdAt: expect.any(String),
|
||||
lastUsedAt: null,
|
||||
expiresAt: null,
|
||||
projectId: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it("ttl should be increased when reading from redis", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
// Mock prisma
|
||||
const mockPrisma = {
|
||||
apiKey: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
// first auth will generate the fast hashed api key
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
// second will add the key to redis
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
// third will read from redis only
|
||||
await new ApiAuthService(
|
||||
mockPrisma as unknown as PrismaClient,
|
||||
redis,
|
||||
).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
const ttl = await redis.ttl(
|
||||
"api-key:ed6818ada09bdad405a74ac72773dde1708dd3fc6fe8bb81b59927400419d227",
|
||||
);
|
||||
|
||||
expect(ttl).toBeGreaterThan(env.LANGFUSE_CACHE_API_KEY_TTL_SECONDS - 2);
|
||||
|
||||
// wait for 5 seconds
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
|
||||
await new ApiAuthService(
|
||||
mockPrisma as unknown as PrismaClient,
|
||||
redis,
|
||||
).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
const ttl2 = await redis.ttl(
|
||||
"api-key:ed6818ada09bdad405a74ac72773dde1708dd3fc6fe8bb81b59927400419d227",
|
||||
);
|
||||
|
||||
expect(ttl2).toBeGreaterThan(env.LANGFUSE_CACHE_API_KEY_TTL_SECONDS - 2);
|
||||
}, 10000);
|
||||
|
||||
it("should delete API keys from cache and db", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
// first auth will generate the fast hashed api key
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
// second will add the key to redis
|
||||
const auth2 = await new ApiAuthService(
|
||||
prisma,
|
||||
redis,
|
||||
).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
expect(auth2.validKey).toBe(true);
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
});
|
||||
|
||||
expect(apiKey).not.toBeNull();
|
||||
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
|
||||
|
||||
const cachedKey = await redis.get(
|
||||
`api-key:${apiKey?.fastHashedSecretKey}`,
|
||||
);
|
||||
expect(cachedKey).not.toBeNull();
|
||||
|
||||
const parsed = ApiKeyZod.parse(JSON.parse(cachedKey!));
|
||||
|
||||
expect(parsed).toEqual({
|
||||
...apiKey,
|
||||
createdAt: apiKey?.createdAt.toISOString(),
|
||||
});
|
||||
|
||||
await new ApiAuthService(prisma, redis).deleteApiKey(
|
||||
apiKey?.id!,
|
||||
apiKey?.projectId!,
|
||||
);
|
||||
|
||||
const deletedApiKey = await prisma.apiKey.findUnique({
|
||||
where: { id: apiKey?.id! },
|
||||
});
|
||||
expect(deletedApiKey).toBeNull();
|
||||
|
||||
const deletedCachedKey = await redis.get(
|
||||
`api-key:${apiKey?.fastHashedSecretKey}`,
|
||||
);
|
||||
expect(deletedCachedKey).toBeNull();
|
||||
});
|
||||
expect(apiKey).not.toBeNull();
|
||||
expect(apiKey?.fastHashedSecretKey).toBeNull();
|
||||
});
|
||||
|
||||
const createAPIKey = async () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
PostDatasetsV1Response,
|
||||
PostDatasetsV2Response,
|
||||
} from "@/src/features/public-api/types/datasets";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
describe("/api/public/datasets and /api/public/dataset-items API Endpoints", () => {
|
||||
const traceId = v4();
|
||||
@@ -295,12 +296,15 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
|
||||
},
|
||||
});
|
||||
expect(dbDatasetItems.length).toBe(5);
|
||||
const dbDatasetItemsApiResponseFormat = dbDatasetItems.map((item) => ({
|
||||
...item,
|
||||
createdAt: item.createdAt.toISOString(),
|
||||
updatedAt: item.updatedAt.toISOString(),
|
||||
datasetName: "dataset-name",
|
||||
}));
|
||||
const dbDatasetItemsApiResponseFormat = dbDatasetItems.map(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
({ projectId, ...item }) => ({
|
||||
...item,
|
||||
createdAt: item.createdAt.toISOString(),
|
||||
updatedAt: item.updatedAt.toISOString(),
|
||||
datasetName: "dataset-name",
|
||||
}),
|
||||
);
|
||||
|
||||
// add another dataset to test the list endpoint
|
||||
await makeZodVerifiedAPICall(
|
||||
@@ -333,7 +337,8 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
|
||||
});
|
||||
expect(dbDatasetItemsOther.length).toBe(1);
|
||||
const dbDatasetItemsOtherApiResponseFormat = dbDatasetItemsOther.map(
|
||||
(item) => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
({ projectId, ...item }) => ({
|
||||
...item,
|
||||
createdAt: item.createdAt.toISOString(),
|
||||
updatedAt: item.updatedAt.toISOString(),
|
||||
@@ -784,12 +789,15 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
|
||||
},
|
||||
});
|
||||
expect(dbRuns.length).toBe(3);
|
||||
const dbRunsApiResponseFormat = dbRuns.map((run) => ({
|
||||
...run,
|
||||
createdAt: run.createdAt.toISOString(),
|
||||
updatedAt: run.updatedAt.toISOString(),
|
||||
datasetName: "dataset-name",
|
||||
}));
|
||||
const dbRunsApiResponseFormat = dbRuns.map(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
({ projectId, ...run }) => ({
|
||||
...run,
|
||||
createdAt: run.createdAt.toISOString(),
|
||||
updatedAt: run.updatedAt.toISOString(),
|
||||
datasetName: "dataset-name",
|
||||
}),
|
||||
);
|
||||
|
||||
// test get runs
|
||||
const getRuns = await makeZodVerifiedAPICall(
|
||||
@@ -835,4 +843,95 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("dataset item ids should be reusable across projects", async () => {
|
||||
const otherProject = await prisma.project.create({
|
||||
data: {
|
||||
name: "other-project",
|
||||
organization: {
|
||||
connectOrCreate: {
|
||||
where: { id: "other-org" },
|
||||
create: { name: "other-org", id: "other-org" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// dataset ids are always generated
|
||||
const datasetBody = {
|
||||
name: "dataset-name",
|
||||
};
|
||||
// dataset, id is generated
|
||||
const apiDataset = await makeZodVerifiedAPICall(
|
||||
PostDatasetsV1Response,
|
||||
"POST",
|
||||
"/api/public/datasets",
|
||||
{ ...datasetBody, metadata: "api-dataset" },
|
||||
);
|
||||
const otherProjDbDataset = await prisma.dataset.create({
|
||||
data: {
|
||||
...datasetBody,
|
||||
projectId: otherProject.id,
|
||||
id: apiDataset.body.id, // use the same id, not possible via api, done to check security of this
|
||||
},
|
||||
});
|
||||
const getApiDataset = await makeZodVerifiedAPICall(
|
||||
GetDatasetV1Response,
|
||||
"GET",
|
||||
`/api/public/datasets/${encodeURIComponent(datasetBody.name)}`,
|
||||
);
|
||||
expect(getApiDataset.body.metadata).toBe("api-dataset");
|
||||
|
||||
// item ids can be set by the user
|
||||
const datasetItemBody = {
|
||||
input: "item-input",
|
||||
id: uuidv4(),
|
||||
};
|
||||
await prisma.datasetItem.create({
|
||||
data: {
|
||||
...datasetItemBody,
|
||||
expectedOutput: "other-proj",
|
||||
projectId: otherProject.id,
|
||||
datasetId: otherProjDbDataset.id,
|
||||
},
|
||||
});
|
||||
|
||||
// dataset item, id is set
|
||||
await makeZodVerifiedAPICall(
|
||||
PostDatasetItemsV1Response,
|
||||
"POST",
|
||||
"/api/public/dataset-items",
|
||||
{
|
||||
...datasetItemBody,
|
||||
expectedOutput: "api-item",
|
||||
datasetName: datasetBody.name,
|
||||
metadata: "api-item",
|
||||
},
|
||||
);
|
||||
const getApiDatasetItem = await makeZodVerifiedAPICall(
|
||||
GetDatasetItemV1Response,
|
||||
"GET",
|
||||
`/api/public/dataset-items/${datasetItemBody.id}`,
|
||||
);
|
||||
expect(getApiDatasetItem.body.metadata).toBe("api-item");
|
||||
const dbItems = await prisma.datasetItem.findMany({
|
||||
where: { id: datasetItemBody.id },
|
||||
});
|
||||
expect(dbItems.length).toBe(2);
|
||||
expect(dbItems).toHaveLength(2);
|
||||
expect(dbItems).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
metadata: "api-item",
|
||||
projectId: apiDataset.body.projectId,
|
||||
id: datasetItemBody.id,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
metadata: null,
|
||||
projectId: otherProject.id,
|
||||
id: datasetItemBody.id,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,12 +71,19 @@ describe("observations.export RPC", () => {
|
||||
expires: "1",
|
||||
user: {
|
||||
id: "clgb17vnp000008jjere5g15i",
|
||||
name: "John Doe",
|
||||
projects: [
|
||||
name: "Demo User",
|
||||
organizations: [
|
||||
{
|
||||
id: projectId,
|
||||
role: "ADMIN",
|
||||
name: "test",
|
||||
id: "seed-org-id",
|
||||
role: "OWNER",
|
||||
plan: "cloud:hobby",
|
||||
cloudConfig: undefined,
|
||||
projects: [
|
||||
{
|
||||
id: projectId,
|
||||
role: "ADMIN",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
featureFlags: {
|
||||
|
||||
@@ -4,8 +4,8 @@ import { v4 } from "uuid";
|
||||
|
||||
import { makeAPICall, pruneDatabase } from "@/src/__tests__/test-utils";
|
||||
import { ModelUsageUnit } from "@langfuse/shared";
|
||||
import { cleanEvent } from "@/src/pages/api/public/ingestion";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { cleanEvent } from "@langfuse/shared/src/server";
|
||||
|
||||
describe("/api/public/ingestion API Endpoint", () => {
|
||||
beforeEach(async () => await pruneDatabase());
|
||||
@@ -707,19 +707,6 @@ describe("/api/public/ingestion API Endpoint", () => {
|
||||
expect(dbScore?.traceId).toBe(traceId);
|
||||
expect(dbScore?.observationId).toBe(generationId);
|
||||
expect(dbScore?.value).toBe(100.5);
|
||||
|
||||
const logEvent = await prisma.events.findFirst({
|
||||
where: {
|
||||
data: {
|
||||
path: ["body", "log"],
|
||||
string_contains: "ERROR",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(logEvent).toBeDefined();
|
||||
expect(logEvent).not.toBeFalsy();
|
||||
expect(JSON.stringify(logEvent?.data)).toContain("KeyError: 'model_name'");
|
||||
});
|
||||
|
||||
it("should upsert threats", async () => {
|
||||
@@ -1140,11 +1127,16 @@ describe("/api/public/ingestion API Endpoint", () => {
|
||||
it("should not override a trace from a different project", async () => {
|
||||
const traceId = v4();
|
||||
const newProjectId = v4();
|
||||
|
||||
await prisma.organization.upsert({
|
||||
where: { id: "other-org" },
|
||||
create: { id: "other-org", name: "other-org" },
|
||||
update: {},
|
||||
});
|
||||
await prisma.project.create({
|
||||
data: {
|
||||
id: newProjectId,
|
||||
name: "another-project",
|
||||
orgId: "other-org",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -18,19 +18,26 @@ describe("llmApiKey.all RPC", () => {
|
||||
user: {
|
||||
id: "user-1",
|
||||
name: "Demo User",
|
||||
projects: [
|
||||
organizations: [
|
||||
{
|
||||
id: projectId,
|
||||
role: "ADMIN",
|
||||
name: "test",
|
||||
id: "seed-org-id",
|
||||
role: "OWNER",
|
||||
plan: "cloud:hobby",
|
||||
cloudConfig: undefined,
|
||||
projects: [
|
||||
{
|
||||
id: projectId,
|
||||
role: "ADMIN",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
featureFlags: {
|
||||
templateFlag: true,
|
||||
evals: true,
|
||||
},
|
||||
admin: true,
|
||||
},
|
||||
environment: {} as any,
|
||||
};
|
||||
|
||||
const ctx = createInnerTRPCContext({ session });
|
||||
|
||||
@@ -110,6 +110,10 @@ describe("/api/public/observations API Endpoint", () => {
|
||||
fetchedObservations.body.data[0]?.calculatedTotalCost,
|
||||
).toBeGreaterThan(0);
|
||||
expect(fetchedObservations.body.data[0]?.promptId).toBe(prompt.id);
|
||||
expect(fetchedObservations.body.data[0]?.promptName).toBe(prompt.name);
|
||||
expect(fetchedObservations.body.data[0]?.promptVersion).toBe(
|
||||
prompt.version,
|
||||
);
|
||||
});
|
||||
it("should fetch all observations, filtered by generations", async () => {
|
||||
await pruneDatabase();
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import { type PrismaClient, type Prompt } from "@prisma/client";
|
||||
import { PromptService, type redis } from "@langfuse/shared/src/server"; // Adjust the import path as needed
|
||||
|
||||
type Redis = NonNullable<typeof redis>;
|
||||
|
||||
describe("PromptService", () => {
|
||||
let promptService: PromptService;
|
||||
let mockPrisma: jest.Mocked<PrismaClient>;
|
||||
let mockRedis: jest.Mocked<Redis>;
|
||||
let mockMetricIncrementer: jest.Mock;
|
||||
|
||||
const mockPrompt: Omit<Prompt, "updatedAt" | "createdAt"> = {
|
||||
id: "1",
|
||||
projectId: "project1",
|
||||
name: "testPrompt",
|
||||
version: 1,
|
||||
prompt: "Test prompt content",
|
||||
labels: ["test"],
|
||||
createdBy: "API",
|
||||
type: "text",
|
||||
isActive: null,
|
||||
config: {},
|
||||
tags: [],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma = {
|
||||
prompt: {
|
||||
findFirst: jest.fn(),
|
||||
},
|
||||
} as unknown as jest.Mocked<PrismaClient>;
|
||||
|
||||
mockRedis = {
|
||||
getex: jest.fn(),
|
||||
set: jest.fn(),
|
||||
setex: jest.fn(),
|
||||
del: jest.fn(),
|
||||
exists: jest.fn(),
|
||||
eval: jest.fn(),
|
||||
sadd: jest.fn(),
|
||||
smembers: jest.fn(),
|
||||
} as unknown as jest.Mocked<Redis>;
|
||||
|
||||
mockMetricIncrementer = jest.fn();
|
||||
|
||||
promptService = new PromptService(
|
||||
mockPrisma,
|
||||
mockRedis,
|
||||
mockMetricIncrementer,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
describe("getPrompt", () => {
|
||||
it("should return cached prompt if available", async () => {
|
||||
mockRedis.exists.mockResolvedValue(0);
|
||||
mockRedis.getex.mockResolvedValue(JSON.stringify(mockPrompt));
|
||||
|
||||
const result = await promptService.getPrompt({
|
||||
projectId: "project1",
|
||||
promptName: "testPrompt",
|
||||
version: 1,
|
||||
label: undefined,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockPrompt);
|
||||
expect(mockMetricIncrementer).toHaveBeenCalledWith("prompt_cache_hit", 1);
|
||||
});
|
||||
|
||||
it("should fetch from database if not in cache", async () => {
|
||||
mockRedis.exists.mockResolvedValue(0);
|
||||
mockRedis.getex.mockResolvedValue(null);
|
||||
mockPrisma.prompt.findFirst.mockResolvedValue(mockPrompt);
|
||||
|
||||
const result = await promptService.getPrompt({
|
||||
projectId: "project1",
|
||||
promptName: "testPrompt",
|
||||
version: 1,
|
||||
label: undefined,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockPrompt);
|
||||
expect(mockPrisma.prompt.findFirst).toHaveBeenCalled();
|
||||
expect(mockMetricIncrementer).toHaveBeenCalledWith(
|
||||
"prompt_cache_miss",
|
||||
1,
|
||||
);
|
||||
|
||||
expect(mockRedis.set).toHaveBeenCalledWith(
|
||||
"prompt:project1:testPrompt:1",
|
||||
JSON.stringify(mockPrompt),
|
||||
"EX",
|
||||
3600,
|
||||
);
|
||||
|
||||
expect(mockRedis.sadd).toHaveBeenCalledWith(
|
||||
"prompt_key_index:project1:testPrompt",
|
||||
"prompt:project1:testPrompt:1",
|
||||
);
|
||||
});
|
||||
|
||||
it("should not use cache if locked", async () => {
|
||||
mockRedis.exists.mockResolvedValue(1);
|
||||
mockPrisma.prompt.findFirst.mockResolvedValue(mockPrompt);
|
||||
|
||||
const result = await promptService.getPrompt({
|
||||
projectId: "project1",
|
||||
promptName: "testPrompt",
|
||||
version: 1,
|
||||
label: undefined,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockPrompt);
|
||||
expect(mockRedis.getex).not.toHaveBeenCalled();
|
||||
expect(mockPrisma.prompt.findFirst).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("lockCache", () => {
|
||||
it("should set a lock in Redis", async () => {
|
||||
await promptService.lockCache({
|
||||
projectId: "project1",
|
||||
promptName: "testPrompt",
|
||||
});
|
||||
|
||||
expect(mockRedis.setex).toHaveBeenCalledWith(
|
||||
"LOCK:prompt:project1:testPrompt",
|
||||
30,
|
||||
"locked",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unlockCache", () => {
|
||||
it("should remove the lock from Redis", async () => {
|
||||
await promptService.unlockCache({
|
||||
projectId: "project1",
|
||||
promptName: "testPrompt",
|
||||
});
|
||||
|
||||
expect(mockRedis.del).toHaveBeenCalledWith(
|
||||
"LOCK:prompt:project1:testPrompt",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidateCache", () => {
|
||||
it("should call deleteKeysByPrefix with correct prefix", async () => {
|
||||
await promptService.invalidateCache({
|
||||
projectId: "project1",
|
||||
promptName: "testPrompt",
|
||||
});
|
||||
|
||||
expect(mockRedis.smembers).toHaveBeenCalledWith(
|
||||
"prompt_key_index:project1:testPrompt",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("caching disabled", () => {
|
||||
beforeEach(() => {
|
||||
promptService = new PromptService(
|
||||
mockPrisma,
|
||||
mockRedis,
|
||||
mockMetricIncrementer,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("should not use cache when disabled", async () => {
|
||||
mockPrisma.prompt.findFirst.mockResolvedValue(mockPrompt);
|
||||
|
||||
const result = await promptService.getPrompt({
|
||||
projectId: "project1",
|
||||
promptName: "testPrompt",
|
||||
version: 1,
|
||||
label: undefined,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockPrompt);
|
||||
expect(mockRedis.getex).not.toHaveBeenCalled();
|
||||
expect(mockPrisma.prompt.findFirst).toHaveBeenCalled();
|
||||
expect(mockMetricIncrementer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("null Redis instance", () => {
|
||||
beforeEach(() => {
|
||||
promptService = new PromptService(
|
||||
mockPrisma,
|
||||
null,
|
||||
mockMetricIncrementer,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("should not use cache with null Redis instance", async () => {
|
||||
mockPrisma.prompt.findFirst.mockResolvedValue(mockPrompt);
|
||||
|
||||
const result = await promptService.getPrompt({
|
||||
projectId: "project1",
|
||||
promptName: "testPrompt",
|
||||
version: 1,
|
||||
label: undefined,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockPrompt);
|
||||
expect(mockPrisma.prompt.findFirst).toHaveBeenCalled();
|
||||
expect(mockMetricIncrementer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPrompt with Redis errors", () => {
|
||||
it("should fallback to database if Redis.exists throws an error", async () => {
|
||||
mockRedis.exists.mockRejectedValue(new Error("Redis error"));
|
||||
mockPrisma.prompt.findFirst.mockResolvedValue(mockPrompt);
|
||||
|
||||
const result = await promptService.getPrompt({
|
||||
projectId: "project1",
|
||||
promptName: "testPrompt",
|
||||
version: 1,
|
||||
label: undefined,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockPrompt);
|
||||
expect(mockPrisma.prompt.findFirst).toHaveBeenCalled();
|
||||
expect(mockMetricIncrementer).toHaveBeenCalledWith(
|
||||
"prompt_cache_miss",
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("should fallback to database if Redis.getex throws an error", async () => {
|
||||
mockRedis.exists.mockResolvedValue(0);
|
||||
mockRedis.getex.mockRejectedValue(new Error("Redis error"));
|
||||
mockPrisma.prompt.findFirst.mockResolvedValue(mockPrompt);
|
||||
|
||||
const result = await promptService.getPrompt({
|
||||
projectId: "project1",
|
||||
promptName: "testPrompt",
|
||||
version: 1,
|
||||
label: undefined,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockPrompt);
|
||||
expect(mockPrisma.prompt.findFirst).toHaveBeenCalled();
|
||||
expect(mockMetricIncrementer).toHaveBeenCalledWith(
|
||||
"prompt_cache_miss",
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("should not cache if Redis.set throws an error after database fetch", async () => {
|
||||
mockRedis.exists.mockResolvedValue(0);
|
||||
mockRedis.getex.mockResolvedValue(null);
|
||||
mockPrisma.prompt.findFirst.mockResolvedValue(mockPrompt);
|
||||
mockRedis.set.mockRejectedValue(new Error("Redis error"));
|
||||
|
||||
const result = await promptService.getPrompt({
|
||||
projectId: "project1",
|
||||
promptName: "testPrompt",
|
||||
version: 1,
|
||||
label: undefined,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockPrompt);
|
||||
expect(mockPrisma.prompt.findFirst).toHaveBeenCalled();
|
||||
expect(mockMetricIncrementer).toHaveBeenCalledWith(
|
||||
"prompt_cache_miss",
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lockCache with Redis errors", () => {
|
||||
it("should throw an error if Redis.setex fails", async () => {
|
||||
mockRedis.setex.mockRejectedValue(new Error("Redis error"));
|
||||
|
||||
await expect(
|
||||
promptService.lockCache({
|
||||
projectId: "project1",
|
||||
promptName: "testPrompt",
|
||||
}),
|
||||
).rejects.toThrow("Redis error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unlockCache with Redis errors", () => {
|
||||
it("should log error but not throw if Redis.del fails", async () => {
|
||||
mockRedis.del.mockRejectedValue(new Error("Redis error"));
|
||||
|
||||
await promptService.unlockCache({
|
||||
projectId: "project1",
|
||||
promptName: "testPrompt",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidateCache with Redis errors", () => {
|
||||
it("should throw an error if Redis.eval fails", async () => {
|
||||
mockRedis.smembers.mockRejectedValue(new Error("Redis error"));
|
||||
|
||||
await expect(
|
||||
promptService.invalidateCache({
|
||||
projectId: "project1",
|
||||
promptName: "testPrompt",
|
||||
}),
|
||||
).rejects.toThrow("Redis error");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -23,11 +23,13 @@ type CreatePromptInDBParams = {
|
||||
prompt: string;
|
||||
labels: string[];
|
||||
version: number;
|
||||
config: Record<string, object | number | string>;
|
||||
config: any;
|
||||
projectId: string;
|
||||
createdBy: string;
|
||||
type?: PromptType;
|
||||
tags?: string[];
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
};
|
||||
const createPromptInDB = async (params: CreatePromptInDBParams) => {
|
||||
return await prisma.prompt.create({
|
||||
@@ -44,6 +46,8 @@ const createPromptInDB = async (params: CreatePromptInDBParams) => {
|
||||
createdBy: params.createdBy,
|
||||
type: params.type,
|
||||
tags: params.tags,
|
||||
createdAt: params.createdAt,
|
||||
updatedAt: params.updatedAt,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -75,7 +79,7 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
const projectId = uuidv4();
|
||||
const response = await makeAPICall(
|
||||
"GET",
|
||||
`/api/public/v2/prompts`,
|
||||
baseURI,
|
||||
undefined,
|
||||
`Bearer ${projectId}`,
|
||||
);
|
||||
@@ -769,19 +773,26 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
});
|
||||
|
||||
const otherProjectId = "239ad00f-562f-411d-af14-831c75ddd875";
|
||||
await prisma.organization.upsert({
|
||||
where: { id: "other-org" },
|
||||
create: { id: "other-org", name: "other-org" },
|
||||
update: {},
|
||||
});
|
||||
await prisma.organizationMembership.upsert({
|
||||
where: {
|
||||
orgId_userId: { orgId: "other-org", userId: "user-test" },
|
||||
},
|
||||
create: { userId: "user-test", orgId: "other-org", role: "OWNER" },
|
||||
update: { role: "OWNER" },
|
||||
});
|
||||
await prisma.project.upsert({
|
||||
where: { id: otherProjectId },
|
||||
create: {
|
||||
id: otherProjectId,
|
||||
name: "demo-app",
|
||||
projectMembers: {
|
||||
create: {
|
||||
role: "OWNER",
|
||||
userId: "user-test",
|
||||
},
|
||||
},
|
||||
orgId: "other-org",
|
||||
},
|
||||
update: {},
|
||||
update: { name: "demo-app", orgId: "other-org" },
|
||||
});
|
||||
|
||||
await createPromptInDB({
|
||||
@@ -833,20 +844,23 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
// Validate prompt-1 meta
|
||||
expect(promptMeta1.name).toBe("prompt-1");
|
||||
expect(promptMeta1.versions).toEqual([1, 2, 4]);
|
||||
expect(promptMeta1.labels).toEqual(["production"]);
|
||||
expect(promptMeta1.labels).toEqual(["production", "version2"]);
|
||||
expect(promptMeta1.tags).toEqual([]);
|
||||
expect(promptMeta1.lastUpdatedAt).toBeDefined();
|
||||
|
||||
// Validate prompt-2 meta
|
||||
expect(promptMeta2.name).toBe("prompt-2");
|
||||
expect(promptMeta2.versions).toEqual([1, 2, 3]);
|
||||
expect(promptMeta2.labels).toEqual(["dev", "production", "staging"]);
|
||||
expect(promptMeta2.tags).toEqual([]);
|
||||
expect(promptMeta2.lastUpdatedAt).toBeDefined();
|
||||
|
||||
// Validate prompt-3 meta
|
||||
expect(promptMeta3.name).toBe("prompt-3");
|
||||
expect(promptMeta3.versions).toEqual([1]);
|
||||
expect(promptMeta3.labels).toEqual(["production"]);
|
||||
expect(promptMeta3.tags).toEqual(["tag-1"]);
|
||||
expect(promptMeta3.lastUpdatedAt).toBeDefined();
|
||||
|
||||
// Validate pagination
|
||||
expect(body.meta.page).toBe(1);
|
||||
@@ -870,7 +884,7 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
expect(body.data).toHaveLength(1);
|
||||
expect(body.data[0].name).toBe("prompt-1");
|
||||
expect(body.data[0].versions).toEqual([1, 2, 4]);
|
||||
expect(body.data[0].labels).toEqual(["production"]);
|
||||
expect(body.data[0].labels).toEqual(["production", "version2"]);
|
||||
expect(body.data[0].tags).toEqual([]);
|
||||
|
||||
// Validate pagination
|
||||
@@ -991,6 +1005,112 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
expect(body.meta.totalPages).toBe(3);
|
||||
expect(body.meta.totalItems).toBe(3);
|
||||
});
|
||||
|
||||
it("should fetch lastConfig correctly for a prompt with multiple versions", async () => {
|
||||
// no filters
|
||||
const response = await makeAPICall("GET", `${baseURI}`);
|
||||
expect(response.status).toBe(200);
|
||||
const body = response.body as unknown as PromptsMetaResponse;
|
||||
|
||||
expect(body.data).toHaveLength(3);
|
||||
expect(body.data.some((promptMeta) => promptMeta.name === "prompt-1")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(body.data.some((promptMeta) => promptMeta.name === "prompt-2")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(body.data.some((promptMeta) => promptMeta.name === "prompt-3")).toBe(
|
||||
true,
|
||||
);
|
||||
const prompt1 = body.data.find(
|
||||
(promptMeta) => promptMeta.name === "prompt-1",
|
||||
);
|
||||
expect(prompt1).toBeDefined();
|
||||
expect(prompt1?.lastConfig).toEqual({ version: 4 });
|
||||
|
||||
const prompt2 = body.data.find(
|
||||
(promptMeta) => promptMeta.name === "prompt-2",
|
||||
);
|
||||
expect(prompt2).toBeDefined();
|
||||
expect(prompt2?.lastConfig).toEqual({});
|
||||
|
||||
// validate with label filter
|
||||
const response2 = await makeAPICall("GET", `${baseURI}?label=version2`);
|
||||
expect(response2.status).toBe(200);
|
||||
const body2 = response2.body as unknown as PromptsMetaResponse;
|
||||
|
||||
expect(body2.data).toHaveLength(1);
|
||||
expect(body2.data[0].name).toBe("prompt-1");
|
||||
expect(body2.data[0].lastConfig).toEqual({ version: 2 });
|
||||
|
||||
// validate with version filter
|
||||
const response3 = await makeAPICall("GET", `${baseURI}?version=1`);
|
||||
expect(response3.status).toBe(200);
|
||||
const body3 = response3.body as unknown as PromptsMetaResponse;
|
||||
|
||||
expect(body3.data).toHaveLength(3);
|
||||
const prompt1v1 = body3.data.find(
|
||||
(promptMeta) => promptMeta.name === "prompt-1",
|
||||
);
|
||||
expect(prompt1v1?.lastConfig).toEqual({ version: 1 });
|
||||
});
|
||||
|
||||
it("should respect the fromUpdatedAt and toUpdatedAt filters on GET /prompts", async () => {
|
||||
// to and from
|
||||
const from = new Date("2024-01-02T00:00:00.000Z");
|
||||
const to = new Date("2024-01-04T00:00:00.000Z");
|
||||
const response = await makeAPICall(
|
||||
"GET",
|
||||
`${baseURI}?fromUpdatedAt=${from.toISOString()}&toUpdatedAt=${to.toISOString()}`,
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const body = response.body as unknown as PromptsMetaResponse;
|
||||
|
||||
expect(body.data).toHaveLength(1);
|
||||
expect(body.data[0].name).toBe("prompt-1");
|
||||
expect(body.data[0].lastUpdatedAt).toBe("2024-01-02T00:00:00.000Z");
|
||||
expect(body.data[0].versions.length).toBe(1);
|
||||
|
||||
expect(body.meta.totalItems).toBe(1);
|
||||
|
||||
// only from
|
||||
const response2 = await makeAPICall(
|
||||
"GET",
|
||||
`${baseURI}?fromUpdatedAt=${from.toISOString()}`,
|
||||
);
|
||||
expect(response2.status).toBe(200);
|
||||
const body2 = response2.body as unknown as PromptsMetaResponse;
|
||||
|
||||
expect(body2.data).toHaveLength(1);
|
||||
expect(body2.data[0].name).toBe("prompt-1");
|
||||
expect(body2.data[0].lastUpdatedAt).toBe("2024-01-04T00:00:00.000Z");
|
||||
expect(body2.data[0].versions.length).toBe(2);
|
||||
|
||||
expect(body2.meta.totalItems).toBe(1);
|
||||
|
||||
// only to
|
||||
const response3 = await makeAPICall(
|
||||
"GET",
|
||||
`${baseURI}?toUpdatedAt=${to.toISOString()}`,
|
||||
);
|
||||
expect(response3.status).toBe(200);
|
||||
const body3 = response3.body as unknown as PromptsMetaResponse;
|
||||
|
||||
expect(body3.data).toHaveLength(3);
|
||||
expect(body3.data[0].name).toBe("prompt-1");
|
||||
expect(body3.data[0].lastUpdatedAt).toBe("2024-01-02T00:00:00.000Z");
|
||||
expect(body3.data[0].versions.length).toBe(2);
|
||||
|
||||
expect(body3.data[1].name).toBe("prompt-2");
|
||||
expect(body3.data[1].lastUpdatedAt).toBe("2000-03-01T00:00:00.000Z");
|
||||
expect(body3.data[1].versions.length).toBe(3);
|
||||
|
||||
expect(body3.data[2].name).toBe("prompt-3");
|
||||
expect(body3.data[2].lastUpdatedAt).toBe("2000-01-01T00:00:00.000Z");
|
||||
expect(body3.data[2].versions.length).toBe(1);
|
||||
|
||||
expect(body3.meta.totalItems).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
const isPrompt = (x: unknown): x is Prompt => {
|
||||
@@ -1029,17 +1149,19 @@ const mockPrompts = [
|
||||
prompt: "prompt-1",
|
||||
createdBy: "user-test",
|
||||
projectId,
|
||||
config: {},
|
||||
config: { version: 1 },
|
||||
version: 1,
|
||||
updatedAt: new Date("2024-01-01T00:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
name: "prompt-1",
|
||||
labels: ["production"],
|
||||
labels: ["production", "version2"],
|
||||
prompt: "prompt-1",
|
||||
createdBy: "user-test",
|
||||
projectId,
|
||||
config: {},
|
||||
config: { version: 2 },
|
||||
version: 2,
|
||||
updatedAt: new Date("2024-01-02T00:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
name: "prompt-1",
|
||||
@@ -1047,8 +1169,9 @@ const mockPrompts = [
|
||||
prompt: "prompt-1",
|
||||
createdBy: "user-test",
|
||||
projectId,
|
||||
config: {},
|
||||
config: { version: 4 },
|
||||
version: 4,
|
||||
updatedAt: new Date("2024-01-04T00:00:00.000Z"),
|
||||
},
|
||||
|
||||
// Prompt with different labels
|
||||
@@ -1060,6 +1183,7 @@ const mockPrompts = [
|
||||
projectId,
|
||||
config: {},
|
||||
version: 1,
|
||||
updatedAt: new Date("2000-01-01T00:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
name: "prompt-2",
|
||||
@@ -1069,6 +1193,7 @@ const mockPrompts = [
|
||||
projectId,
|
||||
config: {},
|
||||
version: 2,
|
||||
updatedAt: new Date("2000-03-01T00:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
name: "prompt-2",
|
||||
@@ -1078,6 +1203,7 @@ const mockPrompts = [
|
||||
projectId,
|
||||
config: {},
|
||||
version: 3,
|
||||
updatedAt: new Date("2000-02-01T00:00:00.000Z"),
|
||||
},
|
||||
|
||||
// Prompt with different labels
|
||||
@@ -1090,6 +1216,7 @@ const mockPrompts = [
|
||||
config: {},
|
||||
tags: ["tag-1"],
|
||||
version: 1,
|
||||
updatedAt: new Date("2000-01-01T00:00:00.000Z"),
|
||||
},
|
||||
|
||||
// Prompt in different project
|
||||
@@ -1101,5 +1228,6 @@ const mockPrompts = [
|
||||
projectId: "239ad00f-562f-411d-af14-831c75ddd875",
|
||||
config: {},
|
||||
version: 1,
|
||||
updatedAt: new Date("2000-01-01T00:00:00.000Z"),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -50,7 +50,7 @@ describe("Build valid SQL queries", () => {
|
||||
table: "traces_scores",
|
||||
values: ["project-id"],
|
||||
strings: [
|
||||
` FROM traces t JOIN scores s ON t.id = s.trace_id AND s.data_type != 'CATEGORICAL' AND t.project_id = s.project_id WHERE t."project_id" = `,
|
||||
` FROM traces t JOIN scores s ON t.id = s.trace_id AND t.project_id = s.project_id WHERE t."project_id" = `,
|
||||
";",
|
||||
],
|
||||
} as const,
|
||||
@@ -182,13 +182,19 @@ describe("Build valid SQL queries", () => {
|
||||
|
||||
describe("should retrieve data", () => {
|
||||
it("should get a simple trace", async () => {
|
||||
await prisma.organization.upsert({
|
||||
where: { id: "other-org" },
|
||||
create: { id: "other-org", name: "other-org" },
|
||||
update: {},
|
||||
});
|
||||
await prisma.project.upsert({
|
||||
where: { id: "different-project-id" },
|
||||
create: {
|
||||
id: "different-project-id",
|
||||
name: "test-project",
|
||||
orgId: "other-org",
|
||||
},
|
||||
update: {},
|
||||
update: { name: "test-project", orgId: "other-org" },
|
||||
});
|
||||
|
||||
await prisma.trace.createMany({
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
GetScoreConfigResponse,
|
||||
PostScoreConfigResponse,
|
||||
GetScoreConfigsResponse,
|
||||
} from "@/src/features/public-api/types/score-configs";
|
||||
} from "@langfuse/shared";
|
||||
|
||||
const configOne = [
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user