Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
12c8968029 | ||
|
|
05dd559d96 | ||
|
|
fffa14d0e9 | ||
|
|
bb30bb3c00 | ||
|
|
b25af3bc98 | ||
|
|
e07024f61f | ||
|
|
af89b4594b | ||
|
|
7c9564374e | ||
|
|
ca1a673687 | ||
|
|
2dd0777b88 | ||
|
|
e93c06c938 | ||
|
|
b918f42971 | ||
|
|
81485397e7 | ||
|
|
1188e914c9 | ||
|
|
0661090238 | ||
|
|
48d5fe700b | ||
|
|
8d4fbfad1d | ||
|
|
a9e2bed00d | ||
|
|
acf11efbe1 | ||
|
|
c92d00f251 | ||
|
|
3e61592cf7 | ||
|
|
83952769b7 | ||
|
|
2291ac6625 | ||
|
|
273e206e2e | ||
|
|
a24c0d776d | ||
|
|
865ef2fbef | ||
|
|
6f62cf5f47 | ||
|
|
31a496880d | ||
|
|
f32255514d | ||
|
|
585ee0ae37 | ||
|
|
6f0c661114 | ||
|
|
febeec4534 | ||
|
|
40a46f1f84 | ||
|
|
a24d6ec742 | ||
|
|
4a55e709ae | ||
|
|
e591f1212d | ||
|
|
78f86264c2 | ||
|
|
4f64c37dad | ||
|
|
8746bcf54f | ||
|
|
8c8e8c11e1 | ||
|
|
2a68c0610b | ||
|
|
5c57b2b8e1 | ||
|
|
3a1f34a54c | ||
|
|
53a6769afb | ||
|
|
0df3b996cc | ||
|
|
04b8137299 | ||
|
|
52b2daf40e | ||
|
|
69442a5e1f | ||
|
|
8a9214c61b | ||
|
|
7b7c428fc3 | ||
|
|
4c6817731f | ||
|
|
1ed426a5e7 | ||
|
|
0106bf6e98 | ||
|
|
c2a68ab339 | ||
|
|
2b27698b8d | ||
|
|
796462c20c | ||
|
|
405b017814 | ||
|
|
b0e056a5b9 | ||
|
|
61641aca3b | ||
|
|
d045869dc4 | ||
|
|
6b9291fef4 | ||
|
|
87f16f58c5 | ||
|
|
700a3cd445 | ||
|
|
0041078830 | ||
|
|
33e4f8743c | ||
|
|
6228956384 | ||
|
|
454773f1c9 | ||
|
|
682983d252 | ||
|
|
6462d45790 | ||
|
|
7413b329d1 | ||
|
|
5532de5871 | ||
|
|
c32e0dbeca | ||
|
|
ab5a281e57 | ||
|
|
dc19bee8e5 | ||
|
|
dd9bf14204 | ||
|
|
82b77bce04 | ||
|
|
d9659ee339 | ||
|
|
324d96fc2e | ||
|
|
ccbe233ff6 | ||
|
|
0223c7eff1 | ||
|
|
4e44987e12 | ||
|
|
e0f605a0f2 | ||
|
|
05a4935a91 | ||
|
|
27e1dca4df | ||
|
|
53e4433032 | ||
|
|
c777d91f13 | ||
|
|
ce1e4318e4 |
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "langfuse dev",
|
||||
"image": "mcr.microsoft.com/devcontainers/javascript-node:20-bookworm",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
||||
"version": "latest",
|
||||
"moby": true
|
||||
}
|
||||
},
|
||||
"forwardPorts": [3000],
|
||||
"name": "langfuse dev",
|
||||
"image": "mcr.microsoft.com/devcontainers/javascript-node:20-bookworm",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
||||
"version": "latest",
|
||||
"moby": true
|
||||
}
|
||||
},
|
||||
"forwardPorts": [3000],
|
||||
|
||||
"onCreateCommand": "npm install -g pnpm@8.15.5",
|
||||
"postCreateCommand": "cp .env.dev.example .env && pnpm run dx-f"
|
||||
"onCreateCommand": "npm install -g pnpm@9.5.0",
|
||||
"postCreateCommand": "cp .env.dev.example .env && pnpm run dx-f"
|
||||
}
|
||||
|
||||
+1
-1
@@ -3,6 +3,6 @@ Dockerfile
|
||||
node_modules
|
||||
npm-debug.log
|
||||
README.md
|
||||
.next
|
||||
**/.next
|
||||
.git
|
||||
**/node_modules
|
||||
+8
-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,5 @@ 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
|
||||
+41
-17
@@ -16,14 +16,17 @@ DATABASE_URL="postgresql://postgres:postgres@db:5432/postgres"
|
||||
# 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
|
||||
@@ -44,6 +47,7 @@ LANGFUSE_CSP_ENFORCE_HTTPS="true"
|
||||
# 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=
|
||||
@@ -104,9 +108,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
|
||||
|
||||
|
||||
@@ -134,28 +135,51 @@ LANGFUSE_CSP_ENFORCE_HTTPS="true"
|
||||
# Used to determine the Sentry sample rate
|
||||
# LANGFUSE_TRACING_SAMPLE_RATE=
|
||||
|
||||
# NewRelic
|
||||
# NEW_RELIC_API_KEY=
|
||||
# OTLP_ENDPOINT=
|
||||
|
||||
# Cloudflare Turnstile
|
||||
# NEXT_PUBLIC_TURNSTILE_SITE_KEY=
|
||||
# TURNSTILE_SECRET_KEY=
|
||||
|
||||
# Betterstack
|
||||
# LANGFUSE_TEAM_BETTERSTACK_TOKEN=
|
||||
|
||||
# Demo project that users can use to try the platform
|
||||
# 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=
|
||||
|
||||
## END Langfuse V3 Ingestion
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 9.5.0
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
@@ -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:
|
||||
@@ -78,7 +79,7 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 9.5.0
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
@@ -93,7 +94,7 @@ jobs:
|
||||
- name: Load default env
|
||||
run: |
|
||||
cp .env.dev.example .env
|
||||
cp .env.dev.example web/.env
|
||||
grep -v '^REDIS_HOST=' .env.dev.example > web/.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:
|
||||
@@ -133,7 +135,7 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 9.5.0
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
@@ -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
|
||||
|
||||
@@ -173,7 +183,7 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 9.5.0
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
@@ -236,7 +246,7 @@ jobs:
|
||||
steps:
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 9.5.0
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
@@ -292,7 +302,7 @@ jobs:
|
||||
labels: ${{ steps.meta-web.outputs.labels }}
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/arm64
|
||||
${{ startsWith(github.ref, 'refs/tags/') && 'linux/arm64' || '' }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta-worker
|
||||
@@ -319,4 +329,4 @@ jobs:
|
||||
labels: ${{ steps.meta-worker.outputs.labels }}
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/arm64
|
||||
${{ startsWith(github.ref, 'refs/tags/') && 'linux/arm64' || '' }}
|
||||
|
||||
@@ -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,4 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
**/newrelic_agent.log
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
|
||||
+60
-10
@@ -53,9 +53,7 @@ A good first step is to search for open [issues](https://github.com/langfuse/lan
|
||||
|
||||
### Architecture Overview
|
||||
|
||||
> [!NOTE]
|
||||
> Infrastructure will change in Langfuse version 3.0. More in the [GitHub Discussions](https://github.com/orgs/langfuse/discussions/1902).
|
||||
> `langfuse/langfuse/worker` is under active development and not recommended for production use in Langfuse 2.x.
|
||||
**Langfuse v2**
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
@@ -93,15 +91,66 @@ flowchart TB
|
||||
I --- Prisma
|
||||
end
|
||||
|
||||
subgraph s5["Application (langfuse/langfuse/worker)"]
|
||||
Worker_API[Public HTTP API]
|
||||
Prisma --- DB
|
||||
JS --- API
|
||||
Python --- API
|
||||
```
|
||||
|
||||
**Langfuse v3 (work in progress, not released yet)**
|
||||
|
||||
> [!NOTE]
|
||||
> Infrastructure will change in Langfuse version 3.0. More in the [GitHub Discussions](https://github.com/orgs/langfuse/discussions/1902).
|
||||
> `langfuse/langfuse/worker` is under active development and not recommended for production use in Langfuse 2.x.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph s4["Clients"]
|
||||
subgraph s2["langfuse/langfuse-python"]
|
||||
Python["Python low-level SDK"]
|
||||
Decorator["observe() decorator"] -->|extends| Python
|
||||
OAI["OpenAI drop-in replacement"] -->|extends| Python
|
||||
Llamaindex["LlamaIndex Integration"] -->|extends| Python
|
||||
LCPYTHON["Langchain Python Integration"] -->|extends| Python
|
||||
Langflow -->|uses| LCPYTHON
|
||||
LiteLLM -->|uses| Python
|
||||
end
|
||||
subgraph s3["langfuse/langfuse-js"]
|
||||
JS["JS SDK"]
|
||||
LCJS["Langchain JS Integration"] -->|extends| JS
|
||||
Flowise -->|uses| LCJS
|
||||
end
|
||||
end
|
||||
|
||||
API --> Worker_API
|
||||
Worker_API --- DB
|
||||
Worker_API --- Redis
|
||||
DB[Postgres Database]
|
||||
Redis[Redis Cache/Queue]
|
||||
Clickhouse[Clickhouse Database]
|
||||
|
||||
subgraph s1["Application (langfuse/langfuse/web)"]
|
||||
API[Public HTTP API]
|
||||
G[TRPC API]
|
||||
I[NextAuth]
|
||||
H[React Frontend]
|
||||
ORM
|
||||
H --> G
|
||||
H --> I
|
||||
G --> I
|
||||
G --- ORM
|
||||
API --- ORM
|
||||
I --- ORM
|
||||
end
|
||||
|
||||
subgraph s5["Application (langfuse/langfuse/worker)"]
|
||||
Worker
|
||||
end
|
||||
|
||||
Worker --- DB
|
||||
Worker --- Redis
|
||||
Worker --- Clickhouse
|
||||
|
||||
ORM --- DB
|
||||
ORM --- Redis
|
||||
ORM --- Clickhouse
|
||||
|
||||
Prisma --- DB
|
||||
JS --- API
|
||||
Python --- API
|
||||
```
|
||||
@@ -116,7 +165,7 @@ flowchart LR
|
||||
Browser ---|Web UI & TRPC API| App
|
||||
Integrations/SDKs ---|Public HTTP API| App
|
||||
subgraph i1["Application Network"]
|
||||
App["Langfuse Application (Docker or Serverless)"]
|
||||
App["Langfuse Application"]
|
||||
end
|
||||
subgraph i2["Database Network"]
|
||||
DB["Postgres Database"]
|
||||
@@ -149,6 +198,7 @@ We built a monorepo using [pnpm](https://pnpm.io/motivation) and [turbo](https:/
|
||||
Requirements
|
||||
|
||||
- Node.js 20 as specified in the [.nvmrc](.nvmrc)
|
||||
- Pnpm v.9.5.0
|
||||
- Docker to run the database locally
|
||||
|
||||
**Note:** You can also simply run Langfuse in a **GitHub Codespace** via the provided devcontainer. To do this, click on the green "Code" button in the top right corner of the repository and select "Open with Codespaces".
|
||||
|
||||
@@ -127,7 +127,6 @@ 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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
+5
-5
@@ -15,23 +15,23 @@
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": "20"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
|
||||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx --max-warnings 0",
|
||||
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@langfuse/shared": "workspace:*",
|
||||
"next": "^14.2.3",
|
||||
"next-auth": "^4.24.7",
|
||||
"zod": "^3.22.4"
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "*",
|
||||
"@repo/typescript-config": "*",
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@types/node": "^20.11.29",
|
||||
"@typescript-eslint/parser": "^7.12.0",
|
||||
"eslint": "^8.57.0",
|
||||
|
||||
@@ -3,7 +3,6 @@ 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(),
|
||||
});
|
||||
|
||||
export const env = { ...sharedEnv, ...EnvSchema.parse(process.env) };
|
||||
|
||||
@@ -9,7 +9,6 @@ service:
|
||||
method: POST
|
||||
path: /scores
|
||||
request: CreateScoreRequest
|
||||
response: Score
|
||||
|
||||
types:
|
||||
CreateScoreRequest:
|
||||
@@ -65,57 +64,45 @@ types:
|
||||
dataType: "BOOLEAN"
|
||||
configId: "1234-5678-90ab-cdef"
|
||||
traceId: "cdef-1234-5678-90ab"
|
||||
NumericScore:
|
||||
BaseScore:
|
||||
properties:
|
||||
id: string
|
||||
traceId: string
|
||||
name: string
|
||||
value:
|
||||
type: double
|
||||
docs: The numeric value of the score
|
||||
source: ScoreSource
|
||||
observationId: optional<string>
|
||||
timestamp: datetime
|
||||
createdAt: datetime
|
||||
updatedAt: datetime
|
||||
authorUserId: optional<string>
|
||||
comment: optional<string>
|
||||
configId:
|
||||
type: optional<string>
|
||||
docs: Reference a score config on a score. When set, config and score name must be equal and value must comply to optionally defined numerical range
|
||||
NumericScore:
|
||||
extends: BaseScore
|
||||
properties:
|
||||
value:
|
||||
type: double
|
||||
docs: The numeric value of the score
|
||||
BooleanScore:
|
||||
extends: BaseScore
|
||||
properties:
|
||||
id: string
|
||||
traceId: string
|
||||
name: string
|
||||
value:
|
||||
type: double
|
||||
docs: The numeric value of the score. Equals 1 for "True" and 0 for "False"
|
||||
stringValue:
|
||||
type: string
|
||||
docs: The string representation of the score value. Is inferred from the numeric value and equals "True" or "False"
|
||||
source: ScoreSource
|
||||
observationId: optional<string>
|
||||
timestamp: datetime
|
||||
comment: optional<string>
|
||||
configId:
|
||||
type: optional<string>
|
||||
docs: Reference a score config on a score. When set, config and score name must be equal
|
||||
CategoricalScore:
|
||||
extends: BaseScore
|
||||
properties:
|
||||
id: string
|
||||
traceId: string
|
||||
name: string
|
||||
value:
|
||||
type: optional<double>
|
||||
docs: Only defined if a config is linked. Represents the numeric category mapping of the stringValue
|
||||
stringValue:
|
||||
type: string
|
||||
docs: The string representation of the score value. If no config is linked, can be any string. Otherwise, must map to a config category
|
||||
source: ScoreSource
|
||||
observationId: optional<string>
|
||||
timestamp: datetime
|
||||
comment: optional<string>
|
||||
configId:
|
||||
type: optional<string>
|
||||
docs: Reference a score config on a score. When set, config and score name must be equal and stringValue must map to a config category
|
||||
Score:
|
||||
discriminant: "dataType"
|
||||
union:
|
||||
@@ -142,6 +129,6 @@ types:
|
||||
CreateScoreValue:
|
||||
discriminated: false
|
||||
union:
|
||||
- string
|
||||
- double
|
||||
- string
|
||||
docs: The value of the score. Must be passed as string for categorical scores, and numeric for boolean and numeric scores
|
||||
|
||||
@@ -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
|
||||
@@ -42,11 +62,18 @@ types:
|
||||
htmlPath:
|
||||
type: string
|
||||
docs: Path of trace in Langfuse UI
|
||||
latency:
|
||||
type: double
|
||||
docs: Latency of trace in seconds
|
||||
totalCost:
|
||||
type: double
|
||||
docs: Cost of trace in USD
|
||||
observations: list<ObservationsView>
|
||||
scores: list<Score>
|
||||
observations:
|
||||
type: list<ObservationsView>
|
||||
docs: List of observations
|
||||
scores:
|
||||
type: list<Score>
|
||||
docs: List of scores
|
||||
Session:
|
||||
properties:
|
||||
id: string
|
||||
@@ -58,36 +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>
|
||||
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
|
||||
@@ -137,57 +225,45 @@ types:
|
||||
properties:
|
||||
value: double
|
||||
label: string
|
||||
NumericScore:
|
||||
BaseScore:
|
||||
properties:
|
||||
id: string
|
||||
traceId: string
|
||||
name: string
|
||||
value:
|
||||
type: double
|
||||
docs: The numeric value of the score
|
||||
source: ScoreSource
|
||||
observationId: optional<string>
|
||||
timestamp: datetime
|
||||
createdAt: datetime
|
||||
updatedAt: datetime
|
||||
authorUserId: optional<string>
|
||||
comment: optional<string>
|
||||
configId:
|
||||
type: optional<string>
|
||||
docs: Reference a score config on a score. When set, config and score name must be equal and value must comply to optionally defined numerical range
|
||||
NumericScore:
|
||||
extends: BaseScore
|
||||
properties:
|
||||
value:
|
||||
type: double
|
||||
docs: The numeric value of the score
|
||||
BooleanScore:
|
||||
extends: BaseScore
|
||||
properties:
|
||||
id: string
|
||||
traceId: string
|
||||
name: string
|
||||
value:
|
||||
type: double
|
||||
docs: The numeric value of the score. Equals 1 for "True" and 0 for "False"
|
||||
stringValue:
|
||||
type: string
|
||||
docs: The string representation of the score value. Is inferred from the numeric value and equals "True" or "False"
|
||||
source: ScoreSource
|
||||
observationId: optional<string>
|
||||
timestamp: datetime
|
||||
comment: optional<string>
|
||||
configId:
|
||||
type: optional<string>
|
||||
docs: Reference a score config on a score. When set, config and score name must be equal
|
||||
CategoricalScore:
|
||||
extends: BaseScore
|
||||
properties:
|
||||
id: string
|
||||
traceId: string
|
||||
name: string
|
||||
value:
|
||||
type: optional<double>
|
||||
docs: Only defined if a config is linked. Represents the numeric category mapping of the stringValue
|
||||
stringValue:
|
||||
type: string
|
||||
docs: The string representation of the score value. If no config is linked, can be any string. Otherwise, must map to a config category
|
||||
source: ScoreSource
|
||||
observationId: optional<string>
|
||||
timestamp: datetime
|
||||
comment: optional<string>
|
||||
configId:
|
||||
type: optional<string>
|
||||
docs: Reference a score config on a score. When set, config and score name must be equal and stringValue must map to a config category
|
||||
Score:
|
||||
discriminant: "dataType"
|
||||
union:
|
||||
@@ -204,8 +280,8 @@ types:
|
||||
CreateScoreValue:
|
||||
discriminated: false
|
||||
union:
|
||||
- string
|
||||
- double
|
||||
- string
|
||||
docs: The value of the score. Must be passed as string for categorical scores, and numeric for boolean and numeric scores
|
||||
Dataset:
|
||||
properties:
|
||||
@@ -241,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:
|
||||
@@ -296,6 +388,7 @@ types:
|
||||
- MILLISECONDS
|
||||
- SECONDS
|
||||
- IMAGES
|
||||
- REQUESTS
|
||||
ObservationLevel:
|
||||
enum:
|
||||
- DEBUG
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -35,7 +35,13 @@ service:
|
||||
parentObservationId: optional<string>
|
||||
fromStartTime:
|
||||
type: optional<datetime>
|
||||
docs: Retrieve only observations with a start_time greater than this datetime (ISO 8601).
|
||||
docs: Retrieve only observations with a start_time or or after this datetime (ISO 8601).
|
||||
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,6 +41,12 @@ 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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -11,7 +11,7 @@ service:
|
||||
method: POST
|
||||
path: /scores
|
||||
request: CreateScoreRequest
|
||||
response: commons.Score
|
||||
response: CreateScoreResponse
|
||||
get:
|
||||
docs: Get a list of scores
|
||||
method: GET
|
||||
@@ -33,7 +33,10 @@ service:
|
||||
docs: Retrieve only scores with this name.
|
||||
fromTimestamp:
|
||||
type: optional<datetime>
|
||||
docs: Retrieve only scores newer than this datetime (ISO 8601).
|
||||
docs: Optional filter to only include scores created on or after a certain datetime (ISO 8601)
|
||||
toTimestamp:
|
||||
type: optional<datetime>
|
||||
docs: Optional filter to only include scores created before a certain datetime (ISO 8601)
|
||||
source:
|
||||
type: optional<commons.ScoreSource>
|
||||
docs: Retrieve only scores from a specific source.
|
||||
@@ -124,7 +127,11 @@ types:
|
||||
dataType: "BOOLEAN"
|
||||
configId: "1234-5678-90ab-cdef"
|
||||
traceId: "cdef-1234-5678-90ab"
|
||||
|
||||
CreateScoreResponse:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
docs: The id of the created object in Langfuse
|
||||
Scores:
|
||||
properties:
|
||||
data: list<commons.Score>
|
||||
|
||||
@@ -6,6 +6,26 @@ service:
|
||||
auth: true
|
||||
base-path: /api/public
|
||||
endpoints:
|
||||
list:
|
||||
docs: Get sessions
|
||||
method: GET
|
||||
path: /sessions
|
||||
request:
|
||||
name: GetSessionsRequest
|
||||
query-parameters:
|
||||
page:
|
||||
type: optional<integer>
|
||||
docs: Page number, starts at 1
|
||||
limit:
|
||||
type: optional<integer>
|
||||
docs: Limit of items per page. If you encounter api issues due to too large page sizes, try to reduce the limit.
|
||||
fromTimestamp:
|
||||
type: optional<datetime>
|
||||
docs: Optional filter to only include sessions created on or after a certain datetime (ISO 8601)
|
||||
toTimestamp:
|
||||
type: optional<datetime>
|
||||
docs: Optional filter to only include sessions created before a certain datetime (ISO 8601)
|
||||
response: PaginatedSessions
|
||||
get:
|
||||
docs: Get a session. Please note that `traces` on this endpoint are not paginated, if you plan to fetch large sessions, consider `GET /api/public/traces?sessionId=<sessionId>`
|
||||
method: GET
|
||||
@@ -15,3 +35,8 @@ service:
|
||||
type: string
|
||||
docs: The unique id of a session
|
||||
response: commons.SessionWithTraces
|
||||
types:
|
||||
PaginatedSessions:
|
||||
properties:
|
||||
data: list<commons.Session>
|
||||
meta: pagination.MetaResponse
|
||||
|
||||
@@ -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:
|
||||
@@ -33,7 +33,10 @@ service:
|
||||
sessionId: optional<string>
|
||||
fromTimestamp:
|
||||
type: optional<datetime>
|
||||
docs: Retrieve only traces newer than this datetime (ISO 8601).
|
||||
docs: Optional filter to only include traces with a trace.timestamp on or after a certain datetime (ISO 8601)
|
||||
toTimestamp:
|
||||
type: optional<datetime>
|
||||
docs: Optional filter to only include traces with a trace.timestamp before a certain datetime (ISO 8601)
|
||||
orderBy:
|
||||
type: optional<string>
|
||||
docs: "Format of the string [field].[asc/desc]. Fields: id, timestamp, name, userId, release, version, public, bookmarked, sessionId. Example: timestamp.asc"
|
||||
@@ -41,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:
|
||||
|
||||
+6
-9
@@ -1,12 +1,11 @@
|
||||
{
|
||||
"name": "langfuse",
|
||||
"version": "2.60.2",
|
||||
"version": "2.69.0",
|
||||
"author": "engineering@langfuse.com",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20",
|
||||
"pnpm": "<9"
|
||||
"node": "20"
|
||||
},
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
@@ -17,8 +16,8 @@
|
||||
"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",
|
||||
"build": "turbo run build",
|
||||
"start": "turbo run start",
|
||||
"dev": "turbo run dev",
|
||||
@@ -35,7 +34,7 @@
|
||||
"husky": "^9.0.11",
|
||||
"prettier": "^3.2.5",
|
||||
"release-it": "^17.3.0",
|
||||
"turbo": "^1.13.3"
|
||||
"turbo": "^1.13.4"
|
||||
},
|
||||
"release-it": {
|
||||
"git": {
|
||||
@@ -80,7 +79,5 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"newrelic": "^11.22.0"
|
||||
}
|
||||
"packageManager": "pnpm@9.5.0"
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"@vercel/style-guide": "^6.0.0",
|
||||
"eslint-config-next": "^14.2.3",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-turbo": "^1.13.3",
|
||||
"eslint-config-turbo": "^1.13.4",
|
||||
"eslint-plugin-only-warn": "^1.1.0",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -6,7 +6,7 @@
|
||||
"main": "./dist/src/index.js",
|
||||
"types": "./dist/src/index.d.ts",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": "20"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
@@ -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": {
|
||||
"@anthropic-ai/tokenizer": "^0.0.4",
|
||||
"@aws-sdk/client-s3": "^3.550.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,18 +62,22 @@
|
||||
"@react-email/render": "^0.0.15",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bullmq": "^5.4.2",
|
||||
"ioredis": "^5.4.1",
|
||||
"js-tiktoken": "^1.0.12",
|
||||
"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",
|
||||
"zod": "^3.22.4",
|
||||
"uuid": "^9.0.1",
|
||||
"zod": "^3.23.8",
|
||||
"zod-to-json-schema": "^3.22.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "*",
|
||||
"@repo/typescript-config": "*",
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@types/lodash": "^4.17.5",
|
||||
"@types/node": "^20.11.29",
|
||||
"@types/nodemailer": "^6.4.15",
|
||||
|
||||
@@ -121,15 +121,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 +143,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 +153,7 @@ export type DatasetRunItems = {
|
||||
};
|
||||
export type DatasetRuns = {
|
||||
id: string;
|
||||
project_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
metadata: unknown | null;
|
||||
@@ -306,6 +309,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;
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
-- Migration script to update score_configs entries
|
||||
-- Set categories to NULL where data_type is 'NUMERIC' and categories is an empty array
|
||||
|
||||
UPDATE score_configs
|
||||
SET categories = NULL
|
||||
WHERE data_type = 'NUMERIC' AND categories IS NOT NULL;
|
||||
@@ -0,0 +1,20 @@
|
||||
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-mini
|
||||
('clyrjp56f0000t0mzapoocd7u', NULL, 'gpt-4o-mini', '(?i)^(gpt-4o-mini)$', NULL, 0.00000015, 0.0000006, NULL, 'TOKENS', 'openai', '{ "tokensPerMessage": 3, "tokensPerName": 1, "tokenizerModel": "gpt-4o" }'),
|
||||
|
||||
-- gpt-4o-mini-2024-07-18
|
||||
('clyrjpbe20000t0mzcbwc42rg', NULL, 'gpt-4o-mini-2024-07-18', '(?i)^(gpt-4o-mini-2024-07-18)$', NULL, 0.00000015, 0.0000006, NULL, 'TOKENS', 'openai', '{ "tokensPerMessage": 3, "tokensPerName": 1, "tokenizerModel": "gpt-4o" }')
|
||||
|
||||
+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" }')
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
previewFeatures = ["tracing", "views", "relationJoins"]
|
||||
previewFeatures = ["tracing", "views", "relationJoins", "metrics"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
@@ -301,9 +301,9 @@ model Observation {
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
|
||||
// GENERATION ONLY
|
||||
model String?
|
||||
internalModel String? @map("internal_model")
|
||||
internalModelId String? @map("internal_model_id")
|
||||
model String? // user-provided model attribute
|
||||
internalModel String? @map("internal_model") // matched model.name that is matched at ingestion time, to be deprecated
|
||||
internalModelId String? @map("internal_model_id") // matched model.id that is matched at ingestion time
|
||||
|
||||
modelParameters Json?
|
||||
input Json?
|
||||
@@ -363,7 +363,7 @@ view ObservationView {
|
||||
statusMessage String? @map("status_message")
|
||||
version String?
|
||||
createdAt DateTime @map("created_at")
|
||||
updateAt DateTime @map("updated_at")
|
||||
updatedAt DateTime @map("updated_at")
|
||||
|
||||
// GENERATION ONLY
|
||||
model String?
|
||||
@@ -376,7 +376,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 +491,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,7 +511,8 @@ 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")
|
||||
@@ -517,11 +522,12 @@ model DatasetItem {
|
||||
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 +542,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 +562,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)
|
||||
@@ -632,7 +642,7 @@ model Model {
|
||||
inputPrice Decimal? @map("input_price")
|
||||
outputPrice Decimal? @map("output_price")
|
||||
totalPrice Decimal? @map("total_price")
|
||||
unit String // TOKENS, CHARACTERS, MILLISECONDS, SECONDS, or IMAGES
|
||||
unit String // TOKENS, CHARACTERS, MILLISECONDS, SECONDS, REQUESTS, or IMAGES
|
||||
tokenizerId String? @map("tokenizer_id")
|
||||
tokenizerConfig Json? @map("tokenizer_config")
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -286,6 +287,7 @@ async function main() {
|
||||
: undefined;
|
||||
const datasetItem = await prisma.datasetItem.create({
|
||||
data: {
|
||||
projectId: project2.id,
|
||||
datasetId: dataset.id,
|
||||
sourceTraceId: sourceObservation?.traceId,
|
||||
sourceObservationId:
|
||||
@@ -312,6 +314,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 +339,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 +355,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);
|
||||
});
|
||||
|
||||
@@ -710,7 +718,7 @@ function createObjects(
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: "How can i create a React component?",
|
||||
content: "How can i create a *React* component?",
|
||||
},
|
||||
]
|
||||
: {
|
||||
@@ -728,37 +736,8 @@ function createObjects(
|
||||
},
|
||||
],
|
||||
},
|
||||
output: {
|
||||
completion: `Creating a React component can be done in two ways: as a functional component or as a class component. Let's start with a basic example of both.
|
||||
|
||||
1. **Functional Component**:
|
||||
|
||||
A functional component is just a plain JavaScript function that accepts props as an argument, and returns a React element. Here's how you can create one:
|
||||
|
||||
|
||||
'import React from 'react'; function Greeting(props) { return <h1>Hello, {props.name}</h1>; } export default Greeting;'
|
||||
|
||||
To use this component in another file, you can do:
|
||||
|
||||
|
||||
'import Greeting from './Greeting'; function App() { return ( <div> <Greeting name="John" /> </div> ); } export default App;'
|
||||
|
||||
2. **Class Component**:
|
||||
|
||||
You can also define components as classes in React. These have some additional features compared to functional components:
|
||||
|
||||
|
||||
'import React, { Component } from 'react'; class Greeting extends Component { render() { return <h1>Hello, {this.props.name}</h1>; } } export default Greeting;'
|
||||
|
||||
And here's how to use this component:
|
||||
|
||||
|
||||
'import Greeting from './Greeting'; class App extends Component { render() { return ( <div> <Greeting name="John" /> </div> ); } } export default App;'
|
||||
|
||||
With the advent of hooks in React, functional components can do everything that class components can do and hence, the community has been favoring functional components over class components.
|
||||
|
||||
Remember to import React at the top of your file whenever you're creating a component, because JSX transpiles to 'React.createElement' calls under the hood.`,
|
||||
},
|
||||
output:
|
||||
"Creating a React component can be done in two ways: as a functional component or as a class component. Let's start with a basic example of both.\n\n**Image**\n\n\n\n1. **Functional Component**:\n\nA functional component is just a plain JavaScript function that accepts props as an argument, and returns a React element. Here's how you can create one:\n\n```javascript\nimport React from 'react';\nfunction Greeting(props) {\n return <h1>Hello, {props.name}</h1>;\n}\nexport default Greeting;\n```\n\nTo use this component in another file, you can do:\n\n```javascript\nimport Greeting from './Greeting';\nfunction App() {\n return (\n <div>\n <Greeting name=\"John\" />\n </div>\n );\n}\nexport default App;\n```\n\n2. **Class Component**:\n\nYou can also define components as classes in React. These have some additional features compared to functional components:\n\n```javascript\nimport React, { Component } from 'react';\nclass Greeting extends Component {\n render() {\n return <h1>Hello, {this.props.name}</h1>;\n }\n}\nexport default Greeting;\n```\n\nAnd here's how to use this component:\n\n```javascript\nimport Greeting from './Greeting';\nclass App extends Component {\n render() {\n return (\n <div>\n <Greeting name=\"John\" />\n </div>\n );\n }\n}\nexport default App;\n```\n\nWith the advent of hooks in React, functional components can do everything that class components can do and hence, the community has been favoring functional components over class components.\n\nRemember to import React at the top of your file whenever you're creating a component, because JSX transpiles to `React.createElement` calls under the hood.",
|
||||
model: model,
|
||||
internalModel: model,
|
||||
modelParameters: {
|
||||
|
||||
@@ -6,4 +6,5 @@ export enum ModelUsageUnit {
|
||||
Seconds = "SECONDS",
|
||||
Milliseconds = "MILLISECONDS",
|
||||
Images = "IMAGES",
|
||||
Requests = "REQUESTS",
|
||||
}
|
||||
|
||||
@@ -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,11 @@ 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(),
|
||||
});
|
||||
|
||||
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({
|
||||
|
||||
@@ -80,6 +80,8 @@ export const availableEvalVariables = [
|
||||
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,
|
||||
|
||||
@@ -10,9 +10,9 @@ export * from "./orderByToPrisma";
|
||||
export * from "./tracesTable";
|
||||
export * from "./server/auth";
|
||||
export * from "./observationsTable";
|
||||
export * from "./features/ingestion/types";
|
||||
export * from "./utils/zod";
|
||||
export * from "./utils/json";
|
||||
export * from "./utils/objects";
|
||||
export { env } from "./env";
|
||||
|
||||
// llm api
|
||||
|
||||
@@ -44,6 +44,7 @@ export enum QueueName {
|
||||
EvaluationExecution = "evaluation-execution-queue", // Worker executes Evals
|
||||
BatchExport = "batch-export-queue",
|
||||
RepeatQueue = "repeat-queue",
|
||||
IngestionFlushQueue = "ingestion-flush-queue",
|
||||
}
|
||||
|
||||
export enum QueueJobs {
|
||||
@@ -51,6 +52,7 @@ export enum QueueJobs {
|
||||
EvaluationExecution = "evaluation-execution-job",
|
||||
BatchExportJob = "batch-export-job",
|
||||
EnqueueBatchExportJobs = "enqueue-batch-export-jobs",
|
||||
FlushIngestionEntity = "flush-ingestion-entity",
|
||||
}
|
||||
|
||||
export type TQueueJobTypes = {
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
};
|
||||
@@ -2,8 +2,17 @@ export * from "./services/S3StorageService";
|
||||
export * from "./services/email/projectInvitation/sendProjectInvitationEmail";
|
||||
export * from "./services/email/batchExportSuccess/sendBatchExportSuccessEmail";
|
||||
export * from "./services/email/passwordReset/sendResetPasswordVerificationRequest";
|
||||
export * from "./services/PromptService";
|
||||
export * from "./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/types";
|
||||
export * from "../server/ingestion/model-match";
|
||||
export * from "../server/ingestion/types";
|
||||
export * from "./redis/redis";
|
||||
export * from "./redis/trace-upsert";
|
||||
export * from "./clickhouse";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+104
-80
@@ -28,7 +28,7 @@ const MixedUsage = z.object({
|
||||
totalCost: z.number().nullish(),
|
||||
});
|
||||
|
||||
export const stringDate = z.string().datetime({ offset: true }).nullish();
|
||||
export const stringDateTime = z.string().datetime({ offset: true }).nullish();
|
||||
|
||||
export const usage = MixedUsage.nullish()
|
||||
// transform mixed usage model input to new one
|
||||
@@ -58,7 +58,7 @@ export const usage = MixedUsage.nullish()
|
||||
|
||||
export const TraceBody = z.object({
|
||||
id: z.string().nullish(),
|
||||
timestamp: stringDate,
|
||||
timestamp: stringDateTime,
|
||||
name: z.string().nullish(),
|
||||
externalId: z.string().nullish(),
|
||||
input: jsonSchema.nullish(),
|
||||
@@ -75,7 +75,7 @@ export const TraceBody = z.object({
|
||||
export const OptionalObservationBody = z.object({
|
||||
traceId: z.string().nullish(),
|
||||
name: z.string().nullish(),
|
||||
startTime: stringDate,
|
||||
startTime: stringDateTime,
|
||||
metadata: jsonSchema.nullish(),
|
||||
input: jsonSchema.nullish(),
|
||||
output: jsonSchema.nullish(),
|
||||
@@ -94,21 +94,27 @@ export const UpdateEventEvent = OptionalObservationBody.extend({
|
||||
});
|
||||
|
||||
export const CreateSpanBody = CreateEventEvent.extend({
|
||||
endTime: stringDate,
|
||||
endTime: stringDateTime,
|
||||
});
|
||||
|
||||
export const UpdateSpanBody = UpdateEventEvent.extend({
|
||||
endTime: stringDate,
|
||||
endTime: stringDateTime,
|
||||
});
|
||||
|
||||
export const CreateGenerationBody = CreateSpanBody.extend({
|
||||
completionStartTime: stringDate,
|
||||
completionStartTime: stringDateTime,
|
||||
model: z.string().nullish(),
|
||||
modelParameters: z
|
||||
.record(
|
||||
z.string(),
|
||||
z
|
||||
.union([z.string(), z.number(), z.boolean(), z.array(z.string())])
|
||||
.union([
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.boolean(),
|
||||
z.array(z.string()),
|
||||
z.record(z.string()),
|
||||
])
|
||||
.nullish()
|
||||
)
|
||||
.nullish(),
|
||||
@@ -124,13 +130,19 @@ export const CreateGenerationBody = CreateSpanBody.extend({
|
||||
});
|
||||
|
||||
export const UpdateGenerationBody = UpdateSpanBody.extend({
|
||||
completionStartTime: stringDate,
|
||||
completionStartTime: stringDateTime,
|
||||
model: z.string().nullish(),
|
||||
modelParameters: z
|
||||
.record(
|
||||
z.string(),
|
||||
z
|
||||
.union([z.string(), z.number(), z.boolean(), z.array(z.string())])
|
||||
.union([
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.boolean(),
|
||||
z.array(z.string()),
|
||||
z.record(z.string()),
|
||||
])
|
||||
.nullish()
|
||||
)
|
||||
.nullish(),
|
||||
@@ -156,71 +168,47 @@ 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({
|
||||
id: z.string().nullish(),
|
||||
traceId: z.string().nullish(),
|
||||
name: z.string().nullish(),
|
||||
startTime: stringDate,
|
||||
endTime: stringDate,
|
||||
startTime: stringDateTime,
|
||||
endTime: stringDateTime,
|
||||
metadata: jsonSchema.nullish(),
|
||||
input: jsonSchema.nullish(),
|
||||
output: jsonSchema.nullish(),
|
||||
@@ -234,8 +222,8 @@ export const LegacySpanPatchSchema = z.object({
|
||||
spanId: z.string(),
|
||||
traceId: z.string().nullish(),
|
||||
name: z.string().nullish(),
|
||||
startTime: stringDate,
|
||||
endTime: stringDate,
|
||||
startTime: stringDateTime,
|
||||
endTime: stringDateTime,
|
||||
metadata: jsonSchema.nullish(),
|
||||
input: jsonSchema.nullish(),
|
||||
output: jsonSchema.nullish(),
|
||||
@@ -248,9 +236,9 @@ export const LegacyGenerationsCreateSchema = z.object({
|
||||
id: z.string().nullish(),
|
||||
traceId: z.string().nullish(),
|
||||
name: z.string().nullish(),
|
||||
startTime: stringDate,
|
||||
endTime: stringDate,
|
||||
completionStartTime: stringDate,
|
||||
startTime: stringDateTime,
|
||||
endTime: stringDateTime,
|
||||
completionStartTime: stringDateTime,
|
||||
model: z.string().nullish(),
|
||||
modelParameters: z
|
||||
.record(
|
||||
@@ -272,9 +260,9 @@ export const LegacyGenerationPatchSchema = z.object({
|
||||
generationId: z.string(),
|
||||
traceId: z.string().nullish(),
|
||||
name: z.string().nullish(),
|
||||
startTime: stringDate,
|
||||
endTime: stringDate,
|
||||
completionStartTime: stringDate,
|
||||
startTime: stringDateTime,
|
||||
endTime: stringDateTime,
|
||||
completionStartTime: stringDateTime,
|
||||
model: z.string().nullish(),
|
||||
modelParameters: z
|
||||
.record(
|
||||
@@ -296,9 +284,9 @@ export const LegacyObservationBody = z.object({
|
||||
traceId: z.string().nullish(),
|
||||
type: z.enum(["GENERATION", "SPAN", "EVENT"]),
|
||||
name: z.string().nullish(),
|
||||
startTime: stringDate,
|
||||
endTime: stringDate,
|
||||
completionStartTime: stringDate,
|
||||
startTime: stringDateTime,
|
||||
endTime: stringDateTime,
|
||||
completionStartTime: stringDateTime,
|
||||
model: z.string().nullish(),
|
||||
modelParameters: z
|
||||
.record(
|
||||
@@ -322,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",
|
||||
@@ -331,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",
|
||||
@@ -346,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),
|
||||
@@ -371,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,
|
||||
@@ -397,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>;
|
||||
@@ -45,9 +45,13 @@ 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",
|
||||
"gpt-4-turbo-preview",
|
||||
"gpt-4-1106-preview",
|
||||
"gpt-4-0613",
|
||||
@@ -64,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,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,251 @@
|
||||
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;
|
||||
|
||||
if (this.cacheEnabled) {
|
||||
this.logInfo("Prompt cache enabled with TTL seconds", this.ttlSeconds);
|
||||
} else {
|
||||
this.logInfo("Prompt cache disabled");
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
}
|
||||
@@ -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,46 @@ 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 undefined;
|
||||
}
|
||||
const arr = z.array(jsonSchemaNullable).safeParse(parsedJson);
|
||||
if (arr.success) {
|
||||
return arr.data;
|
||||
}
|
||||
const obj = z.record(jsonSchemaNullable).safeParse(parsedJson);
|
||||
if (obj.success) {
|
||||
return obj.data;
|
||||
}
|
||||
|
||||
return jsonSchema.parse(parsedJson);
|
||||
} catch (error) {
|
||||
return jsonSchema.parse(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;
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Generated
+15090
-13003
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -17,7 +17,7 @@
|
||||
"db:push": {},
|
||||
"db:seed": {},
|
||||
"models:migrate": {
|
||||
"dependsOn": ["db:generate"]
|
||||
"dependsOn": ["db:generate", "@langfuse/shared#build"]
|
||||
},
|
||||
"db:seed:examples": {},
|
||||
"dev": {
|
||||
|
||||
+14
-10
@@ -5,12 +5,11 @@ FROM --platform=${TARGETPLATFORM:-linux/amd64} node:20-alpine3.20 AS alpine
|
||||
RUN apk update && apk upgrade --no-cache libcrypto3 libssl3 libc6-compat busybox ssl_client
|
||||
|
||||
FROM --platform=${TARGETPLATFORM:-linux/amd64} alpine AS base
|
||||
RUN npm install turbo@^1.13.3 --global
|
||||
RUN npm install turbo@^1.13.4 --global
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
RUN corepack prepare pnpm@8.15.5 --activate
|
||||
|
||||
RUN corepack prepare pnpm@9.5.0 --activate
|
||||
|
||||
FROM --platform=${TARGETPLATFORM:-linux/amd64} base AS pruner
|
||||
|
||||
@@ -19,7 +18,6 @@ WORKDIR /app
|
||||
COPY . .
|
||||
RUN turbo prune --scope=web --docker
|
||||
|
||||
|
||||
FROM --platform=${TARGETPLATFORM:-linux/amd64} base AS builder
|
||||
|
||||
WORKDIR /app
|
||||
@@ -32,6 +30,7 @@ COPY --from=pruner /app/out/json/ .
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
ENV DOCKER_BUILD 1
|
||||
ENV NEXT_MANUAL_SIG_HANDLE true
|
||||
|
||||
# pass public variables in build step
|
||||
ARG NEXT_PUBLIC_LANGFUSE_CLOUD_REGION
|
||||
@@ -53,14 +52,13 @@ RUN rm -f ./web/src/middleware.ts
|
||||
# Learn more here: https://nextjs.org/telemetry
|
||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
ENV NEXT_MANUAL_SIG_HANDLE true
|
||||
|
||||
RUN turbo run build --filter=web
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM --platform=${TARGETPLATFORM:-linux/amd64} base AS runner
|
||||
|
||||
RUN apk add --no-cache dumb-init
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
@@ -68,12 +66,15 @@ ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
# Needed to re-enable validation of environment variables during runtime
|
||||
ENV DOCKER_BUILD 0
|
||||
# Set NEXT_MANUAL_SIG_HANDLE for runtime
|
||||
ENV NEXT_MANUAL_SIG_HANDLE true
|
||||
|
||||
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 .
|
||||
@@ -95,5 +96,8 @@ USER nextjs
|
||||
# Default port to 3000
|
||||
ENV PORT 3000
|
||||
|
||||
# CMD ["node", "server.js"]
|
||||
CMD ["dumb-init", "--", "./web/entrypoint.sh"]
|
||||
|
||||
ENTRYPOINT ["dumb-init", "--", "./web/entrypoint.sh"]
|
||||
|
||||
# startup command
|
||||
CMD ["node", "./web/server.js"]
|
||||
|
||||
+2
-2
@@ -35,5 +35,5 @@ if [ $status -ne 0 ]; then
|
||||
exit $status
|
||||
fi
|
||||
|
||||
# Start server
|
||||
node web/server.js
|
||||
# Run the command passed to the docker image on start
|
||||
exec "$@"
|
||||
@@ -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;
|
||||
|
||||
+30
-16
@@ -80,23 +80,37 @@ const nextConfig = {
|
||||
// Required to check authentication status from langfuse.com
|
||||
...(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined
|
||||
? [
|
||||
{
|
||||
source: "/api/auth/session",
|
||||
headers: [
|
||||
{
|
||||
key: "Access-Control-Allow-Origin",
|
||||
value: "https://langfuse.com",
|
||||
},
|
||||
{ key: "Access-Control-Allow-Credentials", value: "true" },
|
||||
{ key: "Access-Control-Allow-Methods", value: "GET,POST" },
|
||||
{
|
||||
key: "Access-Control-Allow-Headers",
|
||||
value: "Content-Type, Authorization",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
{
|
||||
source: "/api/auth/session",
|
||||
headers: [
|
||||
{
|
||||
key: "Access-Control-Allow-Origin",
|
||||
value: "https://langfuse.com",
|
||||
},
|
||||
{ key: "Access-Control-Allow-Credentials", value: "true" },
|
||||
{ key: "Access-Control-Allow-Methods", value: "GET,POST" },
|
||||
{
|
||||
key: "Access-Control-Allow-Headers",
|
||||
value: "Content-Type, Authorization",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
// all files in /public/generated are public and can be accessed from any origin, e.g. to render an API reference based on our openapi schema
|
||||
{
|
||||
source: "/generated/:path*",
|
||||
headers: [
|
||||
{
|
||||
key: "Access-Control-Allow-Origin",
|
||||
value: "*",
|
||||
},
|
||||
{
|
||||
key: "Access-Control-Allow-Methods",
|
||||
value: "GET",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
|
||||
+19
-22
@@ -1,20 +1,20 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "2.60.2",
|
||||
"version": "2.69.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": "20"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "INLINE_RUNTIME_CHUNK=false dotenv -e ../.env -- next build",
|
||||
"dev": "dotenv -e ../.env -- next dev",
|
||||
"lint": "dotenv -e ../.env -- next lint",
|
||||
"lint": "dotenv -e ../.env -- next lint --max-warnings 0",
|
||||
"lint:fix": "dotenv -e ../.env -- next lint --fix",
|
||||
"prettier": "prettier --write ./src *.{ts,js}",
|
||||
"clean": "rm -rf node_modules",
|
||||
"start": "dotenv -e ../.env -- next start",
|
||||
"test": "dotenv -e ../.env -- jest --runInBand",
|
||||
"start": "dotenv -e ../.env -- sh -c 'NEXT_MANUAL_SIG_HANDLE=true next start'",
|
||||
"test": "dotenv -e ../.env -- jest --runInBand --detectOpenHandles --verbose",
|
||||
"test:watch": "dotenv -e ../.env -- jest --watch --runInBand",
|
||||
"test:e2e": "dotenv -e ../.env -- playwright test",
|
||||
"models:migrate": "dotenv -e ../.env -- tsx scripts/model-match.ts",
|
||||
@@ -36,18 +36,6 @@
|
||||
"@marsidev/react-turnstile": "^0.5.4",
|
||||
"@mui/x-tree-view": "^7.6.2",
|
||||
"@next-auth/prisma-adapter": "^1.0.7",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/auto-instrumentations-node": "^0.48.0",
|
||||
"@opentelemetry/exporter-jaeger": "^1.25.1",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.52.1",
|
||||
"@opentelemetry/resource-detector-aws": "^1.5.2",
|
||||
"@opentelemetry/resource-detector-container": "^0.3.11",
|
||||
"@opentelemetry/resources": "^1.25.1",
|
||||
"@opentelemetry/sdk-node": "^0.52.1",
|
||||
"@opentelemetry/sdk-trace-node": "^1.25.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.25.1",
|
||||
"@opentelemetry/winston-transport": "^0.5.0",
|
||||
"@prisma/instrumentation": "^5.16.1",
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
@@ -72,10 +60,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",
|
||||
@@ -90,6 +77,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",
|
||||
@@ -99,6 +87,9 @@
|
||||
"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",
|
||||
"langchain": "^0.2.6",
|
||||
@@ -110,14 +101,19 @@
|
||||
"next-themes": "^0.3.0",
|
||||
"posthog-js": "^1.122.0",
|
||||
"posthog-node": "^3.6.3",
|
||||
"prexit": "^2.2.0",
|
||||
"prisma": "^5.13.0",
|
||||
"react": "18.2.0",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "^7.51.5",
|
||||
"react-icons": "^5.2.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-responsive": "^10.0.0",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"react18-json-view": "^0.2.8-canary.6",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
"sonner": "^1.4.41",
|
||||
"superjson": "2.2.1",
|
||||
"tailwind-merge": "^2.2.1",
|
||||
@@ -125,7 +121,7 @@
|
||||
"use-query-params": "^2.2.1",
|
||||
"uuid": "^9.0.1",
|
||||
"vaul": "^0.9.1",
|
||||
"zod": "^3.22.4"
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jedmao/location": "^3.0.0",
|
||||
@@ -143,6 +139,7 @@
|
||||
"@types/node": "20.10.5",
|
||||
"@types/react": "^18.2.79",
|
||||
"@types/react-dom": "^18.2.25",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^7.12.0",
|
||||
|
||||
@@ -11,12 +11,8 @@ paths:
|
||||
- Score
|
||||
parameters: []
|
||||
responses:
|
||||
'200':
|
||||
'204':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Score'
|
||||
'400':
|
||||
description: ''
|
||||
content:
|
||||
@@ -90,8 +86,8 @@ components:
|
||||
- traceId
|
||||
- name
|
||||
- value
|
||||
NumericScore:
|
||||
title: NumericScore
|
||||
BaseScore:
|
||||
title: BaseScore
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
@@ -100,10 +96,6 @@ components:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
value:
|
||||
type: number
|
||||
format: double
|
||||
description: The numeric value of the score
|
||||
source:
|
||||
$ref: '#/components/schemas/ScoreSource'
|
||||
observationId:
|
||||
@@ -111,6 +103,14 @@ components:
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
authorUserId:
|
||||
type: string
|
||||
comment:
|
||||
type: string
|
||||
configId:
|
||||
@@ -123,19 +123,26 @@ components:
|
||||
- id
|
||||
- traceId
|
||||
- name
|
||||
- value
|
||||
- source
|
||||
- timestamp
|
||||
- createdAt
|
||||
- updatedAt
|
||||
NumericScore:
|
||||
title: NumericScore
|
||||
type: object
|
||||
properties:
|
||||
value:
|
||||
type: number
|
||||
format: double
|
||||
description: The numeric value of the score
|
||||
required:
|
||||
- value
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/BaseScore'
|
||||
BooleanScore:
|
||||
title: BooleanScore
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
traceId:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
value:
|
||||
type: number
|
||||
format: double
|
||||
@@ -147,38 +154,15 @@ components:
|
||||
description: >-
|
||||
The string representation of the score value. Is inferred from the
|
||||
numeric value and equals "True" or "False"
|
||||
source:
|
||||
$ref: '#/components/schemas/ScoreSource'
|
||||
observationId:
|
||||
type: string
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
comment:
|
||||
type: string
|
||||
configId:
|
||||
type: string
|
||||
description: >-
|
||||
Reference a score config on a score. When set, config and score name
|
||||
must be equal
|
||||
required:
|
||||
- id
|
||||
- traceId
|
||||
- name
|
||||
- value
|
||||
- stringValue
|
||||
- source
|
||||
- timestamp
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/BaseScore'
|
||||
CategoricalScore:
|
||||
title: CategoricalScore
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
traceId:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
value:
|
||||
type: number
|
||||
format: double
|
||||
@@ -190,27 +174,10 @@ components:
|
||||
description: >-
|
||||
The string representation of the score value. If no config is
|
||||
linked, can be any string. Otherwise, must map to a config category
|
||||
source:
|
||||
$ref: '#/components/schemas/ScoreSource'
|
||||
observationId:
|
||||
type: string
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
comment:
|
||||
type: string
|
||||
configId:
|
||||
type: string
|
||||
description: >-
|
||||
Reference a score config on a score. When set, config and score name
|
||||
must be equal and stringValue must map to a config category
|
||||
required:
|
||||
- id
|
||||
- traceId
|
||||
- name
|
||||
- stringValue
|
||||
- source
|
||||
- timestamp
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/BaseScore'
|
||||
Score:
|
||||
title: Score
|
||||
oneOf:
|
||||
@@ -264,9 +231,9 @@ components:
|
||||
CreateScoreValue:
|
||||
title: CreateScoreValue
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: number
|
||||
format: double
|
||||
- type: string
|
||||
description: >-
|
||||
The value of the score. Must be passed as string for categorical scores,
|
||||
and numeric for boolean and numeric scores
|
||||
|
||||
@@ -990,13 +990,30 @@ paths:
|
||||
- name: fromStartTime
|
||||
in: query
|
||||
description: >-
|
||||
Retrieve only observations with a start_time greater than this
|
||||
Retrieve only observations with a start_time or or after this
|
||||
datetime (ISO 8601).
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
- name: toStartTime
|
||||
in: query
|
||||
description: >-
|
||||
Retrieve only observations with a start_time before this datetime
|
||||
(ISO 8601).
|
||||
required: false
|
||||
schema:
|
||||
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: ''
|
||||
@@ -1171,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: ''
|
||||
@@ -1411,7 +1448,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Score'
|
||||
$ref: '#/components/schemas/CreateScoreResponse'
|
||||
'400':
|
||||
description: ''
|
||||
content:
|
||||
@@ -1482,7 +1519,19 @@ paths:
|
||||
nullable: true
|
||||
- name: fromTimestamp
|
||||
in: query
|
||||
description: Retrieve only scores newer than this datetime (ISO 8601).
|
||||
description: >-
|
||||
Optional filter to only include scores created on or after a certain
|
||||
datetime (ISO 8601)
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
- name: toTimestamp
|
||||
in: query
|
||||
description: >-
|
||||
Optional filter to only include scores created before a certain
|
||||
datetime (ISO 8601)
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
@@ -1651,6 +1700,82 @@ paths:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
/api/public/sessions:
|
||||
get:
|
||||
description: Get sessions
|
||||
operationId: sessions_list
|
||||
tags:
|
||||
- Sessions
|
||||
parameters:
|
||||
- name: page
|
||||
in: query
|
||||
description: Page number, starts at 1
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
nullable: true
|
||||
- name: limit
|
||||
in: query
|
||||
description: >-
|
||||
Limit of items per page. If you encounter api issues due to too
|
||||
large page sizes, try to reduce the limit.
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
nullable: true
|
||||
- name: fromTimestamp
|
||||
in: query
|
||||
description: >-
|
||||
Optional filter to only include sessions created on or after a
|
||||
certain datetime (ISO 8601)
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
- name: toTimestamp
|
||||
in: query
|
||||
description: >-
|
||||
Optional filter to only include sessions created before a certain
|
||||
datetime (ISO 8601)
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PaginatedSessions'
|
||||
'400':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
/api/public/sessions/{sessionId}:
|
||||
get:
|
||||
description: >-
|
||||
@@ -1748,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
|
||||
@@ -1789,7 +1914,19 @@ paths:
|
||||
nullable: true
|
||||
- name: fromTimestamp
|
||||
in: query
|
||||
description: Retrieve only traces newer than this datetime (ISO 8601).
|
||||
description: >-
|
||||
Optional filter to only include traces with a trace.timestamp on or
|
||||
after a certain datetime (ISO 8601)
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
- name: toTimestamp
|
||||
in: query
|
||||
description: >-
|
||||
Optional filter to only include traces with a trace.timestamp before
|
||||
a certain datetime (ISO 8601)
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
@@ -1814,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: ''
|
||||
@@ -1859,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
|
||||
@@ -1932,6 +2095,10 @@ components:
|
||||
htmlPath:
|
||||
type: string
|
||||
description: Path of trace in Langfuse UI
|
||||
latency:
|
||||
type: number
|
||||
format: double
|
||||
description: Latency of trace in seconds
|
||||
totalCost:
|
||||
type: number
|
||||
format: double
|
||||
@@ -1940,12 +2107,15 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ObservationsView'
|
||||
description: List of observations
|
||||
scores:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Score'
|
||||
description: List of scores
|
||||
required:
|
||||
- htmlPath
|
||||
- latency
|
||||
- totalCost
|
||||
- observations
|
||||
- scores
|
||||
@@ -1984,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
|
||||
@@ -2043,37 +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:
|
||||
@@ -2176,8 +2385,8 @@ components:
|
||||
required:
|
||||
- value
|
||||
- label
|
||||
NumericScore:
|
||||
title: NumericScore
|
||||
BaseScore:
|
||||
title: BaseScore
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
@@ -2186,10 +2395,6 @@ components:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
value:
|
||||
type: number
|
||||
format: double
|
||||
description: The numeric value of the score
|
||||
source:
|
||||
$ref: '#/components/schemas/ScoreSource'
|
||||
observationId:
|
||||
@@ -2198,6 +2403,15 @@ components:
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
authorUserId:
|
||||
type: string
|
||||
nullable: true
|
||||
comment:
|
||||
type: string
|
||||
nullable: true
|
||||
@@ -2212,19 +2426,26 @@ components:
|
||||
- id
|
||||
- traceId
|
||||
- name
|
||||
- value
|
||||
- source
|
||||
- timestamp
|
||||
- createdAt
|
||||
- updatedAt
|
||||
NumericScore:
|
||||
title: NumericScore
|
||||
type: object
|
||||
properties:
|
||||
value:
|
||||
type: number
|
||||
format: double
|
||||
description: The numeric value of the score
|
||||
required:
|
||||
- value
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/BaseScore'
|
||||
BooleanScore:
|
||||
title: BooleanScore
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
traceId:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
value:
|
||||
type: number
|
||||
format: double
|
||||
@@ -2236,41 +2457,15 @@ components:
|
||||
description: >-
|
||||
The string representation of the score value. Is inferred from the
|
||||
numeric value and equals "True" or "False"
|
||||
source:
|
||||
$ref: '#/components/schemas/ScoreSource'
|
||||
observationId:
|
||||
type: string
|
||||
nullable: true
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
comment:
|
||||
type: string
|
||||
nullable: true
|
||||
configId:
|
||||
type: string
|
||||
nullable: true
|
||||
description: >-
|
||||
Reference a score config on a score. When set, config and score name
|
||||
must be equal
|
||||
required:
|
||||
- id
|
||||
- traceId
|
||||
- name
|
||||
- value
|
||||
- stringValue
|
||||
- source
|
||||
- timestamp
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/BaseScore'
|
||||
CategoricalScore:
|
||||
title: CategoricalScore
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
traceId:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
value:
|
||||
type: number
|
||||
format: double
|
||||
@@ -2283,30 +2478,10 @@ components:
|
||||
description: >-
|
||||
The string representation of the score value. If no config is
|
||||
linked, can be any string. Otherwise, must map to a config category
|
||||
source:
|
||||
$ref: '#/components/schemas/ScoreSource'
|
||||
observationId:
|
||||
type: string
|
||||
nullable: true
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
comment:
|
||||
type: string
|
||||
nullable: true
|
||||
configId:
|
||||
type: string
|
||||
nullable: true
|
||||
description: >-
|
||||
Reference a score config on a score. When set, config and score name
|
||||
must be equal and stringValue must map to a config category
|
||||
required:
|
||||
- id
|
||||
- traceId
|
||||
- name
|
||||
- stringValue
|
||||
- source
|
||||
- timestamp
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/BaseScore'
|
||||
Score:
|
||||
title: Score
|
||||
oneOf:
|
||||
@@ -2346,9 +2521,9 @@ components:
|
||||
CreateScoreValue:
|
||||
title: CreateScoreValue
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: number
|
||||
format: double
|
||||
- type: string
|
||||
description: >-
|
||||
The value of the score. Must be passed as string for categorical scores,
|
||||
and numeric for boolean and numeric scores
|
||||
@@ -2453,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
|
||||
@@ -2563,6 +2746,7 @@ components:
|
||||
- MILLISECONDS
|
||||
- SECONDS
|
||||
- IMAGES
|
||||
- REQUESTS
|
||||
description: Unit of usage in Langfuse
|
||||
ObservationLevel:
|
||||
title: ObservationLevel
|
||||
@@ -2627,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
|
||||
@@ -3523,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:
|
||||
@@ -3787,6 +3980,15 @@ components:
|
||||
- traceId
|
||||
- name
|
||||
- value
|
||||
CreateScoreResponse:
|
||||
title: CreateScoreResponse
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: The id of the created object in Langfuse
|
||||
required:
|
||||
- id
|
||||
Scores:
|
||||
title: Scores
|
||||
type: object
|
||||
@@ -3800,6 +4002,19 @@ components:
|
||||
required:
|
||||
- data
|
||||
- meta
|
||||
PaginatedSessions:
|
||||
title: PaginatedSessions
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Session'
|
||||
meta:
|
||||
$ref: '#/components/schemas/utilsMetaResponse'
|
||||
required:
|
||||
- data
|
||||
- meta
|
||||
Traces:
|
||||
title: Traces
|
||||
type: object
|
||||
|
||||
@@ -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=",
|
||||
"raw": "{{baseUrl}}/api/public/observations?page=&limit=&name=&userId=&type=&traceId=&parentObservationId=&fromStartTime=&toStartTime=&version=",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
@@ -766,7 +766,17 @@
|
||||
{
|
||||
"key": "fromStartTime",
|
||||
"value": "",
|
||||
"description": "Retrieve only observations with a start_time greater than this datetime (ISO 8601)."
|
||||
"description": "Retrieve only observations with a start_time or or after this datetime (ISO 8601)."
|
||||
},
|
||||
{
|
||||
"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": []
|
||||
@@ -867,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}}"
|
||||
],
|
||||
@@ -902,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": []
|
||||
@@ -1101,7 +1121,7 @@
|
||||
"request": {
|
||||
"description": "Get a list of scores",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/scores?page=&limit=&userId=&name=&fromTimestamp=&source=&operator=&value=&scoreIds=&configId=&dataType=",
|
||||
"raw": "{{baseUrl}}/api/public/scores?page=&limit=&userId=&name=&fromTimestamp=&toTimestamp=&source=&operator=&value=&scoreIds=&configId=&dataType=",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
@@ -1134,7 +1154,12 @@
|
||||
{
|
||||
"key": "fromTimestamp",
|
||||
"value": "",
|
||||
"description": "Retrieve only scores newer than this datetime (ISO 8601)."
|
||||
"description": "Optional filter to only include scores created on or after a certain datetime (ISO 8601)"
|
||||
},
|
||||
{
|
||||
"key": "toTimestamp",
|
||||
"value": "",
|
||||
"description": "Optional filter to only include scores created before a certain datetime (ISO 8601)"
|
||||
},
|
||||
{
|
||||
"key": "source",
|
||||
@@ -1247,6 +1272,52 @@
|
||||
"description": null,
|
||||
"name": "Sessions",
|
||||
"item": [
|
||||
{
|
||||
"_type": "endpoint",
|
||||
"name": "List",
|
||||
"request": {
|
||||
"description": "Get sessions",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/sessions?page=&limit=&fromTimestamp=&toTimestamp=",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"public",
|
||||
"sessions"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "page",
|
||||
"value": "",
|
||||
"description": "Page number, starts at 1"
|
||||
},
|
||||
{
|
||||
"key": "limit",
|
||||
"value": "",
|
||||
"description": "Limit of items per page. If you encounter api issues due to too large page sizes, try to reduce the limit."
|
||||
},
|
||||
{
|
||||
"key": "fromTimestamp",
|
||||
"value": "",
|
||||
"description": "Optional filter to only include sessions created on or after a certain datetime (ISO 8601)"
|
||||
},
|
||||
{
|
||||
"key": "toTimestamp",
|
||||
"value": "",
|
||||
"description": "Optional filter to only include sessions created before a certain datetime (ISO 8601)"
|
||||
}
|
||||
],
|
||||
"variable": []
|
||||
},
|
||||
"header": [],
|
||||
"method": "GET",
|
||||
"auth": null,
|
||||
"body": null
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"_type": "endpoint",
|
||||
"name": "Get",
|
||||
@@ -1322,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=&orderBy=&tags=",
|
||||
"raw": "{{baseUrl}}/api/public/traces?page=&limit=&userId=&name=&sessionId=&fromTimestamp=&toTimestamp=&orderBy=&tags=&version=&release=",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
@@ -1362,7 +1433,12 @@
|
||||
{
|
||||
"key": "fromTimestamp",
|
||||
"value": "",
|
||||
"description": "Retrieve only traces newer than this datetime (ISO 8601)."
|
||||
"description": "Optional filter to only include traces with a trace.timestamp on or after a certain datetime (ISO 8601)"
|
||||
},
|
||||
{
|
||||
"key": "toTimestamp",
|
||||
"value": "",
|
||||
"description": "Optional filter to only include traces with a trace.timestamp before a certain datetime (ISO 8601)"
|
||||
},
|
||||
{
|
||||
"key": "orderBy",
|
||||
@@ -1373,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": []
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
|
||||
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 { tokenCount } from "@/src/features/ingest/usage";
|
||||
import { type Prisma } from "@langfuse/shared/src/db";
|
||||
|
||||
async function main() {
|
||||
|
||||
@@ -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 "@/src/features/public-api/types/scores";
|
||||
|
||||
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();
|
||||
@@ -95,7 +96,8 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
|
||||
expect(dbDataset.length).toBeGreaterThan(0);
|
||||
|
||||
// get dataset (v1) excluding items and runs
|
||||
const getDatasetV1 = await makeAPICall(
|
||||
const getDatasetV1 = await makeZodVerifiedAPICall(
|
||||
GetDatasetV1Response,
|
||||
"GET",
|
||||
`/api/public/datasets/${encodeURIComponent("dataset + name")}`,
|
||||
);
|
||||
@@ -294,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(
|
||||
@@ -332,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(),
|
||||
@@ -783,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(
|
||||
@@ -834,4 +843,89 @@ 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",
|
||||
},
|
||||
});
|
||||
|
||||
// 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,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { makeAPICall, pruneDatabase } from "@/src/__tests__/test-utils";
|
||||
import {
|
||||
makeZodVerifiedAPICall,
|
||||
pruneDatabase,
|
||||
} from "@/src/__tests__/test-utils";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { PostTracesV1Response } from "@/src/features/public-api/types/traces";
|
||||
import { PostEventsV1Response } from "@/src/features/public-api/types/events";
|
||||
|
||||
describe("/api/public/events API Endpoint", () => {
|
||||
beforeEach(async () => await pruneDatabase());
|
||||
@@ -12,15 +17,20 @@ describe("/api/public/events API Endpoint", () => {
|
||||
|
||||
const traceId = uuidv4();
|
||||
|
||||
await makeAPICall("POST", "/api/public/traces", {
|
||||
id: traceId,
|
||||
name: "trace-name",
|
||||
userId: "user-1",
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
});
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
id: traceId,
|
||||
name: "trace-name",
|
||||
userId: "user-1",
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
const dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
@@ -32,16 +42,21 @@ describe("/api/public/events API Endpoint", () => {
|
||||
expect(dbTrace[0]?.id).toBe(traceId);
|
||||
|
||||
const eventId = uuidv4();
|
||||
const createEvent = await makeAPICall("POST", "/api/public/events", {
|
||||
id: eventId,
|
||||
traceId: traceId,
|
||||
name: "event-name",
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
input: { input: "value" },
|
||||
output: { output: "value" },
|
||||
metadata: { meta: "value" },
|
||||
version: "2.0.0",
|
||||
});
|
||||
const createEvent = await makeZodVerifiedAPICall(
|
||||
PostEventsV1Response,
|
||||
"POST",
|
||||
"/api/public/events",
|
||||
{
|
||||
id: eventId,
|
||||
traceId: traceId,
|
||||
name: "event-name",
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
input: { input: "value" },
|
||||
output: { output: "value" },
|
||||
metadata: { meta: "value" },
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
expect(createEvent.status).toBe(200);
|
||||
const dbEvent = await prisma.observation.findUnique({
|
||||
@@ -66,16 +81,21 @@ describe("/api/public/events API Endpoint", () => {
|
||||
const traceId = uuidv4();
|
||||
const eventId = uuidv4();
|
||||
|
||||
const createEvent = await makeAPICall("POST", "/api/public/events", {
|
||||
id: eventId,
|
||||
traceId: traceId,
|
||||
name: "event-name",
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
input: { input: "value" },
|
||||
output: { output: "value" },
|
||||
metadata: { meta: "value" },
|
||||
version: "2.0.0",
|
||||
});
|
||||
const createEvent = await makeZodVerifiedAPICall(
|
||||
PostEventsV1Response,
|
||||
"POST",
|
||||
"/api/public/events",
|
||||
{
|
||||
id: eventId,
|
||||
traceId: traceId,
|
||||
name: "event-name",
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
input: { input: "value" },
|
||||
output: { output: "value" },
|
||||
metadata: { meta: "value" },
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
expect(createEvent.status).toBe(200);
|
||||
const dbEvent = await prisma.observation.findUnique({
|
||||
@@ -93,15 +113,20 @@ describe("/api/public/events API Endpoint", () => {
|
||||
expect(dbEvent?.metadata).toEqual({ meta: "value" });
|
||||
expect(dbEvent?.version).toBe("2.0.0");
|
||||
|
||||
await makeAPICall("POST", "/api/public/traces", {
|
||||
id: traceId,
|
||||
name: "trace-name",
|
||||
userId: "user-1",
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
});
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
id: traceId,
|
||||
name: "trace-name",
|
||||
userId: "user-1",
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
const dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
@@ -118,15 +143,20 @@ describe("/api/public/events API Endpoint", () => {
|
||||
|
||||
const traceId = uuidv4();
|
||||
|
||||
const response = await makeAPICall("POST", "/api/public/traces", {
|
||||
externalId: traceId,
|
||||
name: "trace-name",
|
||||
userId: "user-1",
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
});
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
externalId: traceId,
|
||||
name: "trace-name",
|
||||
userId: "user-1",
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
@@ -141,17 +171,22 @@ describe("/api/public/events API Endpoint", () => {
|
||||
expect(dbTrace[0]?.id).not.toBe(traceId);
|
||||
|
||||
const eventId = uuidv4();
|
||||
const createEvent = await makeAPICall("POST", "/api/public/events", {
|
||||
id: eventId,
|
||||
traceIdType: "EXTERNAL",
|
||||
traceId: dbTrace[0]?.id,
|
||||
name: "event-name",
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
input: { input: "value" },
|
||||
output: { output: "value" },
|
||||
metadata: { meta: "value" },
|
||||
version: "2.0.0",
|
||||
});
|
||||
const createEvent = await makeZodVerifiedAPICall(
|
||||
PostEventsV1Response,
|
||||
"POST",
|
||||
"/api/public/events",
|
||||
{
|
||||
id: eventId,
|
||||
traceIdType: "EXTERNAL",
|
||||
traceId: dbTrace[0]?.id,
|
||||
name: "event-name",
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
input: { input: "value" },
|
||||
output: { output: "value" },
|
||||
metadata: { meta: "value" },
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
expect(createEvent.status).toBe(200);
|
||||
const dbEvent = await prisma.observation.findUnique({
|
||||
@@ -174,16 +209,21 @@ describe("/api/public/events API Endpoint", () => {
|
||||
const eventName = uuidv4();
|
||||
|
||||
const spanId = uuidv4();
|
||||
const createEvent = await makeAPICall("POST", "/api/public/events", {
|
||||
id: spanId,
|
||||
name: eventName,
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:00:00.000Z",
|
||||
input: { input: "value" },
|
||||
output: { output: "value" },
|
||||
metadata: { meta: "value" },
|
||||
version: "2.0.0",
|
||||
});
|
||||
const createEvent = await makeZodVerifiedAPICall(
|
||||
PostEventsV1Response,
|
||||
"POST",
|
||||
"/api/public/events",
|
||||
{
|
||||
id: spanId,
|
||||
name: eventName,
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:00:00.000Z",
|
||||
input: { input: "value" },
|
||||
output: { output: "value" },
|
||||
metadata: { meta: "value" },
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
const dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
@@ -215,14 +255,19 @@ describe("/api/public/events API Endpoint", () => {
|
||||
const generationName = uuidv4();
|
||||
|
||||
const spanId = uuidv4();
|
||||
const createSpan = await makeAPICall("POST", "/api/public/events", {
|
||||
id: spanId,
|
||||
name: generationName,
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
input: { key: "value" },
|
||||
metadata: { key: "value" },
|
||||
version: "2.0.0",
|
||||
});
|
||||
const createSpan = await makeZodVerifiedAPICall(
|
||||
PostEventsV1Response,
|
||||
"POST",
|
||||
"/api/public/events",
|
||||
{
|
||||
id: spanId,
|
||||
name: generationName,
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
input: { key: "value" },
|
||||
metadata: { key: "value" },
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
const dbEvent = await prisma.observation.findFirstOrThrow({
|
||||
where: {
|
||||
|
||||
@@ -2,9 +2,19 @@
|
||||
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { makeAPICall, pruneDatabase } from "@/src/__tests__/test-utils";
|
||||
import {
|
||||
makeAPICall,
|
||||
makeZodVerifiedAPICall,
|
||||
pruneDatabase,
|
||||
} from "@/src/__tests__/test-utils";
|
||||
import { ModelUsageUnit } from "@langfuse/shared";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { PostTracesV1Response } from "@/src/features/public-api/types/traces";
|
||||
import {
|
||||
PatchGenerationsV1Response,
|
||||
PostGenerationsV1Body,
|
||||
PostGenerationsV1Response,
|
||||
} from "@/src/features/public-api/types/generations";
|
||||
|
||||
describe("/api/public/generations API Endpoint", () => {
|
||||
beforeEach(async () => await pruneDatabase());
|
||||
@@ -90,15 +100,20 @@ describe("/api/public/generations API Endpoint", () => {
|
||||
|
||||
const traceId = uuidv4();
|
||||
|
||||
await makeAPICall("POST", "/api/public/traces", {
|
||||
id: traceId,
|
||||
name: "trace-name",
|
||||
userId: "user-1",
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
});
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
id: traceId,
|
||||
name: "trace-name",
|
||||
userId: "user-1",
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
const dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
@@ -110,7 +125,8 @@ describe("/api/public/generations API Endpoint", () => {
|
||||
expect(dbTrace[0]?.id).toBe(traceId);
|
||||
|
||||
const generationId = uuidv4();
|
||||
const createGeneration = await makeAPICall(
|
||||
const createGeneration = await makeZodVerifiedAPICall(
|
||||
PostGenerationsV1Response,
|
||||
"POST",
|
||||
"/api/public/generations",
|
||||
{
|
||||
@@ -164,7 +180,8 @@ describe("/api/public/generations API Endpoint", () => {
|
||||
const traceId = uuidv4();
|
||||
|
||||
const generationId = uuidv4();
|
||||
const createGeneration = await makeAPICall(
|
||||
const createGeneration = await makeZodVerifiedAPICall(
|
||||
PostGenerationsV1Body,
|
||||
"POST",
|
||||
"/api/public/generations",
|
||||
{
|
||||
@@ -201,15 +218,20 @@ describe("/api/public/generations API Endpoint", () => {
|
||||
expect(dbGeneration?.metadata).toEqual({ key: "value" });
|
||||
expect(dbGeneration?.version).toBe("2.0.0");
|
||||
|
||||
await makeAPICall("POST", "/api/public/traces", {
|
||||
id: traceId,
|
||||
name: "trace-name",
|
||||
userId: "user-1",
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
});
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
id: traceId,
|
||||
name: "trace-name",
|
||||
userId: "user-1",
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
const dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
@@ -224,15 +246,20 @@ describe("/api/public/generations API Endpoint", () => {
|
||||
it("should create generation after trace ignoring externalId", async () => {
|
||||
const traceId = uuidv4();
|
||||
|
||||
const response = await makeAPICall("POST", "/api/public/traces", {
|
||||
externalId: traceId,
|
||||
name: "trace-name",
|
||||
userId: "user-1",
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
});
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
externalId: traceId,
|
||||
name: "trace-name",
|
||||
userId: "user-1",
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
@@ -247,7 +274,8 @@ describe("/api/public/generations API Endpoint", () => {
|
||||
expect(dbTrace[0]?.id).not.toBe(traceId);
|
||||
|
||||
const generationId = uuidv4();
|
||||
const createGeneration = await makeAPICall(
|
||||
const createGeneration = await makeZodVerifiedAPICall(
|
||||
PostGenerationsV1Body,
|
||||
"POST",
|
||||
"/api/public/generations",
|
||||
{
|
||||
@@ -290,7 +318,8 @@ describe("/api/public/generations API Endpoint", () => {
|
||||
const generationName = uuidv4();
|
||||
|
||||
const generationId = uuidv4();
|
||||
const createGeneration = await makeAPICall(
|
||||
const createGeneration = await makeZodVerifiedAPICall(
|
||||
PostGenerationsV1Body,
|
||||
"POST",
|
||||
"/api/public/generations",
|
||||
{
|
||||
@@ -340,7 +369,8 @@ describe("/api/public/generations API Endpoint", () => {
|
||||
const generationName = uuidv4();
|
||||
|
||||
const generationId = uuidv4();
|
||||
const createGeneration = await makeAPICall(
|
||||
const createGeneration = await makeZodVerifiedAPICall(
|
||||
PostGenerationsV1Body,
|
||||
"POST",
|
||||
"/api/public/generations",
|
||||
{
|
||||
@@ -368,7 +398,8 @@ describe("/api/public/generations API Endpoint", () => {
|
||||
const generationId2 = uuidv4();
|
||||
const generationName2 = uuidv4();
|
||||
|
||||
const createGeneration2 = await makeAPICall(
|
||||
const createGeneration2 = await makeZodVerifiedAPICall(
|
||||
PostGenerationsV1Body,
|
||||
"POST",
|
||||
"/api/public/generations",
|
||||
{
|
||||
@@ -401,7 +432,8 @@ describe("/api/public/generations API Endpoint", () => {
|
||||
|
||||
const generationId = uuidv4();
|
||||
const externalTraceId = uuidv4();
|
||||
const createGeneration = await makeAPICall(
|
||||
const createGeneration = await makeZodVerifiedAPICall(
|
||||
PostGenerationsV1Body,
|
||||
"POST",
|
||||
"/api/public/generations",
|
||||
{
|
||||
@@ -437,7 +469,8 @@ describe("/api/public/generations API Endpoint", () => {
|
||||
const generationName = uuidv4();
|
||||
|
||||
const generationId = uuidv4();
|
||||
const createGeneration = await makeAPICall(
|
||||
const createGeneration = await makeZodVerifiedAPICall(
|
||||
PostGenerationsV1Body,
|
||||
"POST",
|
||||
"/api/public/generations",
|
||||
{
|
||||
@@ -488,7 +521,8 @@ describe("/api/public/generations API Endpoint", () => {
|
||||
const generationName = uuidv4();
|
||||
|
||||
const generationId = uuidv4();
|
||||
const createGeneration = await makeAPICall(
|
||||
const createGeneration = await makeZodVerifiedAPICall(
|
||||
PostGenerationsV1Body,
|
||||
"POST",
|
||||
"/api/public/generations",
|
||||
{
|
||||
@@ -506,7 +540,8 @@ describe("/api/public/generations API Endpoint", () => {
|
||||
|
||||
expect(createGeneration.status).toBe(200);
|
||||
|
||||
const updateGeneration = await makeAPICall(
|
||||
const updateGeneration = await makeZodVerifiedAPICall(
|
||||
PatchGenerationsV1Response,
|
||||
"PATCH",
|
||||
"/api/public/generations",
|
||||
{
|
||||
@@ -540,7 +575,8 @@ describe("/api/public/generations API Endpoint", () => {
|
||||
const generationName = uuidv4();
|
||||
|
||||
const generationId = uuidv4();
|
||||
const createGeneration = await makeAPICall(
|
||||
const createGeneration = await makeZodVerifiedAPICall(
|
||||
PostGenerationsV1Body,
|
||||
"POST",
|
||||
"/api/public/generations",
|
||||
{
|
||||
|
||||
@@ -57,6 +57,17 @@ describe("/api/public/ingestion API Endpoint", () => {
|
||||
expectedCompletionTokens: 2,
|
||||
expectedTotalTokens: 3,
|
||||
},
|
||||
{
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 2,
|
||||
unit: ModelUsageUnit.Requests,
|
||||
},
|
||||
expectedUnit: ModelUsageUnit.Requests,
|
||||
expectedPromptTokens: 1,
|
||||
expectedCompletionTokens: 2,
|
||||
expectedTotalTokens: 3,
|
||||
},
|
||||
{
|
||||
usage: {
|
||||
input: 30,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { containsAnyMarkdown } from "@/src/components/schemas/MarkdownSchema";
|
||||
|
||||
describe("containsAnyMarkdown Function", () => {
|
||||
it("Detects simple bold using asterisks", () => {
|
||||
expect(containsAnyMarkdown("This is **bold** text")).toBe(true);
|
||||
});
|
||||
|
||||
it("Detects simple italics using asterisks", () => {
|
||||
expect(containsAnyMarkdown("This is *italic* text")).toBe(true);
|
||||
});
|
||||
|
||||
it("Detects bold using underscores", () => {
|
||||
expect(containsAnyMarkdown("This is __bold__ text")).toBe(true);
|
||||
});
|
||||
|
||||
it("Detects italics using underscores", () => {
|
||||
expect(containsAnyMarkdown("This is _italic_ text")).toBe(true);
|
||||
});
|
||||
|
||||
it("Detects inline code with backticks", () => {
|
||||
expect(containsAnyMarkdown("This is `code` inline")).toBe(true);
|
||||
});
|
||||
|
||||
it("Detects fenced code blocks", () => {
|
||||
const codeBlock = "```\nlet x = 10;\n```";
|
||||
expect(containsAnyMarkdown(codeBlock)).toBe(true);
|
||||
});
|
||||
|
||||
it("Detects headers", () => {
|
||||
expect(containsAnyMarkdown("# Header 1")).toBe(true);
|
||||
expect(containsAnyMarkdown("## Header 2")).toBe(true);
|
||||
});
|
||||
|
||||
it("Detects unordered lists", () => {
|
||||
expect(containsAnyMarkdown("- List item 1\n- List item 2")).toBe(true);
|
||||
});
|
||||
|
||||
it("Detects ordered lists", () => {
|
||||
expect(containsAnyMarkdown("1. First item\n2. Second item")).toBe(true);
|
||||
});
|
||||
|
||||
it("Detects blockquotes", () => {
|
||||
expect(containsAnyMarkdown("> This is a blockquote")).toBe(true);
|
||||
});
|
||||
|
||||
it("Detects links", () => {
|
||||
expect(containsAnyMarkdown("[Google](http://www.google.com)")).toBe(true);
|
||||
});
|
||||
|
||||
it("Detects images", () => {
|
||||
expect(containsAnyMarkdown("")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("Returns false for non-markdown text", () => {
|
||||
expect(
|
||||
containsAnyMarkdown("This is plain text without any markdown syntax"),
|
||||
).toBe(false);
|
||||
expect(containsAnyMarkdown("12345")).toBe(false);
|
||||
expect(containsAnyMarkdown("Simple text.")).toBe(false);
|
||||
});
|
||||
|
||||
it("Detects combination of markdown elements", () => {
|
||||
const complexMarkdown = "**Bold** and _italic_ and `code`";
|
||||
expect(containsAnyMarkdown(complexMarkdown)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,10 @@
|
||||
import { makeAPICall, pruneDatabase } from "@/src/__tests__/test-utils";
|
||||
import {
|
||||
makeZodVerifiedAPICall,
|
||||
pruneDatabase,
|
||||
} from "@/src/__tests__/test-utils";
|
||||
import { PostGenerationsV1Response } from "@/src/features/public-api/types/generations";
|
||||
import { GetMetricsDailyV1Response } from "@/src/features/public-api/types/metrics";
|
||||
import { PostTracesV1Response } from "@/src/features/public-api/types/traces";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
describe("/api/public/metrics/daily API Endpoint", () => {
|
||||
@@ -6,53 +12,89 @@ describe("/api/public/metrics/daily API Endpoint", () => {
|
||||
afterEach(async () => await pruneDatabase());
|
||||
|
||||
it("should handle daily metrics correctly", async () => {
|
||||
await pruneDatabase();
|
||||
|
||||
// Create traces with observations on different days
|
||||
const traceId1 = uuidv4();
|
||||
const traceId2 = uuidv4();
|
||||
await makeAPICall("POST", "/api/public/traces", {
|
||||
id: traceId1,
|
||||
timestamp: "2021-01-01T00:00:00.000Z",
|
||||
name: "trace-day-1",
|
||||
userId: "user-daily-metrics",
|
||||
projectId: "project-daily-metrics",
|
||||
});
|
||||
await makeAPICall("POST", "/api/public/traces", {
|
||||
id: traceId2,
|
||||
timestamp: "2021-01-02T00:00:00.000Z",
|
||||
name: "trace-day-2",
|
||||
userId: "user-daily-metrics",
|
||||
projectId: "project-daily-metrics",
|
||||
});
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
id: traceId1,
|
||||
timestamp: "2021-01-01T00:00:00.000Z",
|
||||
name: "trace-day-1",
|
||||
userId: "user-daily-metrics",
|
||||
projectId: "project-daily-metrics",
|
||||
},
|
||||
);
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
id: traceId2,
|
||||
timestamp: "2021-01-02T00:00:00.000Z",
|
||||
name: "trace-day-2",
|
||||
userId: "user-daily-metrics",
|
||||
projectId: "project-daily-metrics",
|
||||
},
|
||||
);
|
||||
|
||||
// Simulate observations with usage metrics on different days
|
||||
await makeAPICall("POST", "/api/public/generations", {
|
||||
traceId: traceId1,
|
||||
model: "modelA",
|
||||
usage: { input: 100, output: 200, total: 300 },
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:01:00.000Z",
|
||||
});
|
||||
await makeAPICall("POST", "/api/public/generations", {
|
||||
traceId: traceId2,
|
||||
model: "modelB",
|
||||
usage: { input: 333 },
|
||||
startTime: "2021-01-02T00:00:00.000Z",
|
||||
endTime: "2021-01-02T00:02:00.000Z",
|
||||
});
|
||||
await makeAPICall("POST", "/api/public/generations", {
|
||||
traceId: traceId2,
|
||||
model: "modelC",
|
||||
usage: { input: 666, output: 777, totalCost: 1024.22 },
|
||||
startTime: "2021-01-02T00:00:00.000Z",
|
||||
endTime: "2021-01-02T00:04:00.000Z",
|
||||
});
|
||||
await makeZodVerifiedAPICall(
|
||||
PostGenerationsV1Response,
|
||||
"POST",
|
||||
"/api/public/generations",
|
||||
{
|
||||
traceId: traceId1,
|
||||
model: "modelA",
|
||||
usage: { input: 100, output: 200, total: 300 },
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:01:00.000Z",
|
||||
},
|
||||
);
|
||||
await makeZodVerifiedAPICall(
|
||||
PostGenerationsV1Response,
|
||||
"POST",
|
||||
"/api/public/generations",
|
||||
{
|
||||
traceId: traceId2,
|
||||
model: "modelB",
|
||||
usage: { input: 333 },
|
||||
startTime: "2021-01-02T00:00:00.000Z",
|
||||
endTime: "2021-01-02T00:02:00.000Z",
|
||||
},
|
||||
);
|
||||
await makeZodVerifiedAPICall(
|
||||
PostGenerationsV1Response,
|
||||
"POST",
|
||||
"/api/public/generations",
|
||||
{
|
||||
traceId: traceId2,
|
||||
model: "modelC",
|
||||
usage: { input: 666, output: 777, totalCost: 1024.22 },
|
||||
startTime: "2021-01-02T00:00:00.000Z",
|
||||
endTime: "2021-01-02T00:04:00.000Z",
|
||||
},
|
||||
);
|
||||
await makeZodVerifiedAPICall(
|
||||
PostGenerationsV1Response,
|
||||
"POST",
|
||||
"/api/public/generations",
|
||||
{
|
||||
traceId: traceId2,
|
||||
usage: { output: 300 },
|
||||
startTime: "2021-01-02T00:00:00.000Z",
|
||||
endTime: "2021-01-02T00:04:00.000Z",
|
||||
},
|
||||
);
|
||||
|
||||
// Retrieve the daily metrics
|
||||
const dailyMetricsResponse = await makeAPICall<{
|
||||
data: Array<Record<string, unknown>>;
|
||||
}>("GET", `/api/public/metrics/daily`);
|
||||
const dailyMetricsResponse = await makeZodVerifiedAPICall(
|
||||
GetMetricsDailyV1Response,
|
||||
"GET",
|
||||
`/api/public/metrics/daily`,
|
||||
);
|
||||
const dailyMetricsData = dailyMetricsResponse.body.data;
|
||||
|
||||
// Check if the daily metrics are calculated correctly
|
||||
@@ -81,6 +123,15 @@ describe("/api/public/metrics/daily API Endpoint", () => {
|
||||
countTraces: 1,
|
||||
totalCost: 1024.22,
|
||||
},
|
||||
{
|
||||
model: null,
|
||||
countObservations: 1,
|
||||
countTraces: 1,
|
||||
inputUsage: 0,
|
||||
outputUsage: 300,
|
||||
totalCost: 0,
|
||||
totalUsage: 300,
|
||||
},
|
||||
]);
|
||||
|
||||
if (!dailyMetricsData[1])
|
||||
@@ -100,4 +151,48 @@ describe("/api/public/metrics/daily API Endpoint", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle daily metrics correctly when there is no data", async () => {
|
||||
// Retrieve the daily metrics
|
||||
const dailyMetricsResponse = await makeZodVerifiedAPICall(
|
||||
GetMetricsDailyV1Response,
|
||||
"GET",
|
||||
`/api/public/metrics/daily`,
|
||||
);
|
||||
const dailyMetricsData = dailyMetricsResponse.body.data;
|
||||
|
||||
// Check if the daily metrics are calculated correctly
|
||||
expect(dailyMetricsData).toHaveLength(0); // No data
|
||||
});
|
||||
|
||||
it("should handle daily metrics correctly when there is just a trace", async () => {
|
||||
const traceId1 = uuidv4();
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
id: traceId1,
|
||||
timestamp: "2021-01-01T00:00:00.000Z",
|
||||
name: "trace-day-1",
|
||||
userId: "user-daily-metrics",
|
||||
projectId: "project-daily-metrics",
|
||||
},
|
||||
);
|
||||
|
||||
// Retrieve the daily metrics
|
||||
const dailyMetricsResponse = await makeZodVerifiedAPICall(
|
||||
GetMetricsDailyV1Response,
|
||||
"GET",
|
||||
`/api/public/metrics/daily`,
|
||||
);
|
||||
const dailyMetricsData = dailyMetricsResponse.body.data;
|
||||
|
||||
// Check if the daily metrics are calculated correctly
|
||||
expect(dailyMetricsData).toHaveLength(1);
|
||||
expect(dailyMetricsData[0].date).toBe("2021-01-01");
|
||||
expect(dailyMetricsData[0].countTraces).toBe(1);
|
||||
expect(dailyMetricsData[0].totalCost).toEqual(0);
|
||||
expect(dailyMetricsData[0].usage).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,10 +2,16 @@
|
||||
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { makeAPICall, pruneDatabase } from "@/src/__tests__/test-utils";
|
||||
import {
|
||||
makeZodVerifiedAPICall,
|
||||
pruneDatabase,
|
||||
} from "@/src/__tests__/test-utils";
|
||||
import { ModelUsageUnit } from "@langfuse/shared";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { type ObservationView } from "@langfuse/shared";
|
||||
import {
|
||||
GetObservationV1Response,
|
||||
GetObservationsV1Response,
|
||||
} from "@/src/features/public-api/types/observations";
|
||||
|
||||
describe("/api/public/observations API Endpoint", () => {
|
||||
beforeEach(async () => await pruneDatabase());
|
||||
@@ -79,7 +85,8 @@ describe("/api/public/observations API Endpoint", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const fetchedObservations = await makeAPICall(
|
||||
const fetchedObservations = await makeZodVerifiedAPICall(
|
||||
GetObservationsV1Response,
|
||||
"GET",
|
||||
"/api/public/observations",
|
||||
undefined,
|
||||
@@ -87,13 +94,6 @@ describe("/api/public/observations API Endpoint", () => {
|
||||
|
||||
expect(fetchedObservations.status).toBe(200);
|
||||
|
||||
if (!isObservationList(fetchedObservations.body)) {
|
||||
throw new Error(
|
||||
"Expected body to be an array of observations" +
|
||||
JSON.stringify(fetchedObservations.body),
|
||||
);
|
||||
}
|
||||
|
||||
expect(fetchedObservations.body.data.length).toBe(1);
|
||||
expect(fetchedObservations.body.data[0]?.traceId).toBe(traceId);
|
||||
expect(fetchedObservations.body.data[0]?.input).toEqual({ key: "input" });
|
||||
@@ -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();
|
||||
@@ -183,7 +187,8 @@ describe("/api/public/observations API Endpoint", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const fetchedObservations = await makeAPICall(
|
||||
const fetchedObservations = await makeZodVerifiedAPICall(
|
||||
GetObservationsV1Response,
|
||||
"GET",
|
||||
"/api/public/observations?type=GENERATION",
|
||||
undefined,
|
||||
@@ -193,10 +198,6 @@ describe("/api/public/observations API Endpoint", () => {
|
||||
|
||||
expect(fetchedObservations.status).toBe(200);
|
||||
|
||||
if (!isObservationList(fetchedObservations.body)) {
|
||||
throw new Error("Expected body to be an array of observations");
|
||||
}
|
||||
|
||||
expect(fetchedObservations.body.data.length).toBe(1);
|
||||
expect(fetchedObservations.body.data[0]?.traceId).toBe(traceId);
|
||||
expect(fetchedObservations.body.data[0]?.input).toEqual({ key: "input" });
|
||||
@@ -205,37 +206,236 @@ describe("/api/public/observations API Endpoint", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const isObservationList = (val: unknown): val is ObservationResponse => {
|
||||
return (
|
||||
typeof val === "object" &&
|
||||
val !== null &&
|
||||
"data" in val &&
|
||||
Array.isArray(val.data) &&
|
||||
val.data.every(
|
||||
(element) =>
|
||||
typeof element === "object" &&
|
||||
element !== null &&
|
||||
"id" in element &&
|
||||
"traceId" in element &&
|
||||
"name" in element &&
|
||||
"startTime" in element &&
|
||||
"endTime" in element &&
|
||||
"model" in element &&
|
||||
"input" in element &&
|
||||
"output" in element &&
|
||||
"metadata" in element &&
|
||||
"version" in element &&
|
||||
"modelId" in element &&
|
||||
"inputPrice" in element &&
|
||||
"outputPrice" in element &&
|
||||
"totalPrice" in element &&
|
||||
"calculatedInputCost" in element &&
|
||||
"calculatedOutputCost" in element &&
|
||||
"calculatedTotalCost" in element,
|
||||
)
|
||||
);
|
||||
};
|
||||
it("GET /observations with timestamp filters and pagination", async () => {
|
||||
await prisma.trace.create({
|
||||
data: {
|
||||
id: "trace-id",
|
||||
name: "trace-name",
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
},
|
||||
});
|
||||
await prisma.observation.createMany({
|
||||
data: [
|
||||
{
|
||||
id: "observation-2021-01-01",
|
||||
traceId: "trace-id",
|
||||
name: "generation-name",
|
||||
startTime: new Date("2021-01-01T00:00:00.000Z"),
|
||||
endTime: new Date("2021-01-01T00:00:00.000Z"),
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
type: "GENERATION",
|
||||
},
|
||||
{
|
||||
id: "observation-2021-02-01",
|
||||
traceId: "trace-id",
|
||||
name: "generation-name",
|
||||
startTime: new Date("2021-02-01T00:00:00.000Z"),
|
||||
endTime: new Date("2021-02-01T00:00:00.000Z"),
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
type: "SPAN",
|
||||
},
|
||||
{
|
||||
id: "observation-2021-03-01",
|
||||
traceId: "trace-id",
|
||||
name: "generation-name",
|
||||
startTime: new Date("2021-03-01T00:00:00.000Z"),
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
type: "EVENT",
|
||||
},
|
||||
{
|
||||
id: "observation-2021-04-01",
|
||||
traceId: "trace-id",
|
||||
name: "generation-name",
|
||||
startTime: new Date("2021-04-01T00:00:00.000Z"),
|
||||
endTime: new Date("2021-04-01T00:00:00.000Z"),
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
type: "GENERATION",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
type ObservationResponse = {
|
||||
data: ObservationView[];
|
||||
};
|
||||
const fromTimestamp = "2021-02-01T00:00:00.000Z";
|
||||
const toTimestamp = "2021-04-01T00:00:00.000Z";
|
||||
|
||||
// Test with both fromTimestamp and toTimestamp
|
||||
let fetchedObservations = await makeZodVerifiedAPICall(
|
||||
GetObservationsV1Response,
|
||||
"GET",
|
||||
`/api/public/observations?fromStartTime=${fromTimestamp}&toStartTime=${toTimestamp}`,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(fetchedObservations.body.data.length).toBe(2);
|
||||
expect(fetchedObservations.body.data[0]?.id).toBe("observation-2021-03-01");
|
||||
expect(fetchedObservations.body.data[1]?.id).toBe("observation-2021-02-01");
|
||||
expect(fetchedObservations.body.meta.totalItems).toBe(2);
|
||||
|
||||
// Test with only fromTimestamp
|
||||
fetchedObservations = await makeZodVerifiedAPICall(
|
||||
GetObservationsV1Response,
|
||||
"GET",
|
||||
`/api/public/observations?fromStartTime=${fromTimestamp}`,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(fetchedObservations.body.data.length).toBe(3);
|
||||
expect(fetchedObservations.body.data[0]?.id).toBe("observation-2021-04-01");
|
||||
expect(fetchedObservations.body.data[1]?.id).toBe("observation-2021-03-01");
|
||||
expect(fetchedObservations.body.data[2]?.id).toBe("observation-2021-02-01");
|
||||
expect(fetchedObservations.body.meta.totalItems).toBe(3);
|
||||
|
||||
// Test with only toTimestamp
|
||||
fetchedObservations = await makeZodVerifiedAPICall(
|
||||
GetObservationsV1Response,
|
||||
"GET",
|
||||
`/api/public/observations?toStartTime=${toTimestamp}`,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(fetchedObservations.body.data.length).toBe(3);
|
||||
expect(fetchedObservations.body.data[0]?.id).toBe("observation-2021-03-01");
|
||||
expect(fetchedObservations.body.data[1]?.id).toBe("observation-2021-02-01");
|
||||
expect(fetchedObservations.body.data[2]?.id).toBe("observation-2021-01-01");
|
||||
expect(fetchedObservations.body.meta.totalItems).toBe(3);
|
||||
|
||||
// test pagination only
|
||||
fetchedObservations = await makeZodVerifiedAPICall(
|
||||
GetObservationsV1Response,
|
||||
"GET",
|
||||
`/api/public/observations?limit=1&page=2`,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(fetchedObservations.body.data.length).toBe(1);
|
||||
expect(fetchedObservations.body.data[0]?.id).toBe("observation-2021-03-01");
|
||||
expect(fetchedObservations.body.meta).toMatchObject({
|
||||
totalItems: 4,
|
||||
totalPages: 4,
|
||||
page: 2,
|
||||
limit: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("Get a single EVENT from /observations/:id", async () => {
|
||||
const traceId = uuidv4();
|
||||
await prisma.trace.create({
|
||||
data: {
|
||||
id: traceId,
|
||||
name: "trace-name",
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
},
|
||||
});
|
||||
|
||||
// EVENT
|
||||
const eventId = uuidv4();
|
||||
await prisma.observation.create({
|
||||
data: {
|
||||
id: eventId,
|
||||
traceId: traceId,
|
||||
name: "generation-name",
|
||||
startTime: new Date("2021-01-01T00:00:00.000Z"),
|
||||
type: "EVENT",
|
||||
project: {
|
||||
connect: { id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a" },
|
||||
},
|
||||
},
|
||||
});
|
||||
const getEventRes = await makeZodVerifiedAPICall(
|
||||
GetObservationV1Response,
|
||||
"GET",
|
||||
"/api/public/observations/" + eventId,
|
||||
);
|
||||
expect(getEventRes.body).toMatchObject({
|
||||
id: eventId,
|
||||
traceId: traceId,
|
||||
type: "EVENT",
|
||||
});
|
||||
});
|
||||
it("Get a single GENERATION from /observations/:id", async () => {
|
||||
const traceId = uuidv4();
|
||||
await prisma.trace.create({
|
||||
data: {
|
||||
id: traceId,
|
||||
name: "trace-name",
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
},
|
||||
});
|
||||
|
||||
const generationId = uuidv4();
|
||||
await prisma.observation.create({
|
||||
data: {
|
||||
id: generationId,
|
||||
traceId: traceId,
|
||||
name: "generation-name",
|
||||
startTime: new Date("2021-01-01T00:00:00.000Z"),
|
||||
endTime: new Date("2021-01-01T00:00:00.000Z"),
|
||||
model: "gpt-3.5-turbo",
|
||||
internalModel: "gpt-3.5-turbo",
|
||||
modelParameters: { key: "value" },
|
||||
input: { key: "input" },
|
||||
output: { key: "output" },
|
||||
promptTokens: 10,
|
||||
completionTokens: 20,
|
||||
totalTokens: 30,
|
||||
version: "2.0.0",
|
||||
unit: ModelUsageUnit.Tokens,
|
||||
type: "GENERATION",
|
||||
project: {
|
||||
connect: { id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a" },
|
||||
},
|
||||
},
|
||||
});
|
||||
const getGenerationRes = await makeZodVerifiedAPICall(
|
||||
GetObservationV1Response,
|
||||
"GET",
|
||||
"/api/public/observations/" + generationId,
|
||||
);
|
||||
expect(getGenerationRes.body).toMatchObject({
|
||||
id: generationId,
|
||||
traceId: traceId,
|
||||
input: { key: "input" },
|
||||
output: { key: "output" },
|
||||
type: "GENERATION",
|
||||
});
|
||||
});
|
||||
it("Get a single SPAN from /observations/:id", async () => {
|
||||
const traceId = uuidv4();
|
||||
await prisma.trace.create({
|
||||
data: {
|
||||
id: traceId,
|
||||
name: "trace-name",
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
},
|
||||
});
|
||||
|
||||
const spanId = uuidv4();
|
||||
await prisma.observation.create({
|
||||
data: {
|
||||
id: spanId,
|
||||
traceId: traceId,
|
||||
name: "generation-name",
|
||||
startTime: new Date("2021-01-01T00:00:00.000Z"),
|
||||
endTime: new Date("2021-01-01T00:00:00.000Z"),
|
||||
modelParameters: { key: "value" },
|
||||
input: { key: "input" },
|
||||
output: { key: "output" },
|
||||
version: "2.0.0",
|
||||
type: "SPAN",
|
||||
project: {
|
||||
connect: { id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a" },
|
||||
},
|
||||
},
|
||||
});
|
||||
const getSpanRes = await makeZodVerifiedAPICall(
|
||||
GetObservationV1Response,
|
||||
"GET",
|
||||
"/api/public/observations/" + spanId,
|
||||
);
|
||||
expect(getSpanRes.body).toMatchObject({
|
||||
id: spanId,
|
||||
traceId: traceId,
|
||||
input: { key: "input" },
|
||||
output: { key: "output" },
|
||||
type: "SPAN",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -219,7 +219,7 @@ describe("/api/public/prompts API Endpoint", () => {
|
||||
});
|
||||
|
||||
// Third prompt is deactivated
|
||||
const prompt3 = await makeAPICall("POST", "/api/public/prompts", {
|
||||
await makeAPICall("POST", "/api/public/prompts", {
|
||||
name: "prompt-name",
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
prompt: "prompt3",
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
@@ -833,20 +837,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 +877,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 +998,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 +1142,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 +1162,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 +1176,7 @@ const mockPrompts = [
|
||||
projectId,
|
||||
config: {},
|
||||
version: 1,
|
||||
updatedAt: new Date("2000-01-01T00:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
name: "prompt-2",
|
||||
@@ -1069,6 +1186,7 @@ const mockPrompts = [
|
||||
projectId,
|
||||
config: {},
|
||||
version: 2,
|
||||
updatedAt: new Date("2000-03-01T00:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
name: "prompt-2",
|
||||
@@ -1078,6 +1196,7 @@ const mockPrompts = [
|
||||
projectId,
|
||||
config: {},
|
||||
version: 3,
|
||||
updatedAt: new Date("2000-02-01T00:00:00.000Z"),
|
||||
},
|
||||
|
||||
// Prompt with different labels
|
||||
@@ -1090,6 +1209,7 @@ const mockPrompts = [
|
||||
config: {},
|
||||
tags: ["tag-1"],
|
||||
version: 1,
|
||||
updatedAt: new Date("2000-01-01T00:00:00.000Z"),
|
||||
},
|
||||
|
||||
// Prompt in different project
|
||||
@@ -1101,5 +1221,6 @@ const mockPrompts = [
|
||||
projectId: "239ad00f-562f-411d-af14-831c75ddd875",
|
||||
config: {},
|
||||
version: 1,
|
||||
updatedAt: new Date("2000-01-01T00:00:00.000Z"),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
GetScoresResponse,
|
||||
} from "@/src/features/public-api/types/scores";
|
||||
import { z } from "zod";
|
||||
import { PostTracesV1Response } from "@/src/features/public-api/types/traces";
|
||||
|
||||
const traceId = "de98afa2-89dc-47e9-9924-33f1490fdaf4";
|
||||
|
||||
@@ -21,15 +22,20 @@ describe("/api/public/scores API Endpoint", () => {
|
||||
beforeEach(async () => {
|
||||
if (should_prune_db) await pruneDatabase();
|
||||
|
||||
await makeAPICall("POST", "/api/public/traces", {
|
||||
id: traceId,
|
||||
name: "trace-name",
|
||||
userId: "user-1",
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
});
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
id: traceId,
|
||||
name: "trace-name",
|
||||
userId: "user-1",
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
});
|
||||
afterEach(async () => {
|
||||
if (should_prune_db) await pruneDatabase();
|
||||
@@ -105,22 +111,60 @@ describe("/api/public/scores API Endpoint", () => {
|
||||
expect(fetchedScore.body?.observationId).toBeNull();
|
||||
});
|
||||
|
||||
it("should GET score with minimal score data and minimal trace data", async () => {
|
||||
const minimalTraceId = uuidv4();
|
||||
await makeAPICall("POST", "/api/public/traces", {
|
||||
id: minimalTraceId,
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
});
|
||||
const dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
id: minimalTraceId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbTrace.length).toBeGreaterThan(0);
|
||||
expect(dbTrace[0]?.id).toBe(minimalTraceId);
|
||||
|
||||
const minimalScoreId = uuidv4();
|
||||
|
||||
await makeAPICall("POST", "/api/public/scores", {
|
||||
id: minimalScoreId,
|
||||
name: "score-name",
|
||||
value: 100,
|
||||
traceId: minimalTraceId,
|
||||
});
|
||||
|
||||
const fetchedScore = await makeZodVerifiedAPICall(
|
||||
GetScoreResponse,
|
||||
"GET",
|
||||
`/api/public/scores/${minimalScoreId}`,
|
||||
);
|
||||
|
||||
expect(fetchedScore.status).toBe(200);
|
||||
});
|
||||
|
||||
it("should create score for a generation", async () => {
|
||||
await pruneDatabase();
|
||||
|
||||
const generationId = uuidv4();
|
||||
|
||||
await makeAPICall("POST", "/api/public/generations", {
|
||||
id: generationId,
|
||||
name: "generation-name",
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:00:00.000Z",
|
||||
model: "model-name",
|
||||
modelParameters: { key: "value" },
|
||||
prompt: { key: "value" },
|
||||
metadata: { key: "value" },
|
||||
version: "2.0.0",
|
||||
});
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/generations",
|
||||
{
|
||||
id: generationId,
|
||||
name: "generation-name",
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:00:00.000Z",
|
||||
model: "model-name",
|
||||
modelParameters: { key: "value" },
|
||||
prompt: { key: "value" },
|
||||
metadata: { key: "value" },
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
const dbGeneration = await prisma.observation.findMany({
|
||||
where: {
|
||||
@@ -399,10 +443,8 @@ describe("/api/public/scores API Endpoint", () => {
|
||||
|
||||
expect(createScore.status).toBe(400);
|
||||
expect(createScore.body).toMatchObject({
|
||||
message: "Invalid request data",
|
||||
errors: [
|
||||
message:
|
||||
"Data type mismatch based on config: expected NUMERIC, got CATEGORICAL",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -451,10 +493,8 @@ describe("/api/public/scores API Endpoint", () => {
|
||||
|
||||
expect(createScore.status).toBe(400);
|
||||
expect(createScore.body).toMatchObject({
|
||||
message: "Invalid request data",
|
||||
errors: [
|
||||
message:
|
||||
"Data type mismatch based on config: expected CATEGORICAL, got NUMERIC",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -557,7 +597,8 @@ describe("/api/public/scores API Endpoint", () => {
|
||||
error: [
|
||||
{
|
||||
code: "custom",
|
||||
message: "Value must be either 0 or 1",
|
||||
message:
|
||||
"Value must be a number equal to either 0 or 1 for data type BOOLEAN",
|
||||
path: ["value"],
|
||||
},
|
||||
],
|
||||
@@ -664,10 +705,8 @@ describe("/api/public/scores API Endpoint", () => {
|
||||
|
||||
expect(createScore.status).toBe(400);
|
||||
expect(createScore.body).toMatchObject({
|
||||
message: "Invalid request data",
|
||||
errors: [
|
||||
message:
|
||||
"Ingested score body not valid against provided config: - Value exceeds maximum value of 0 defined in config",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -682,18 +721,23 @@ describe("/api/public/scores API Endpoint", () => {
|
||||
expect(dbTrace[0]?.id).toBe(traceId);
|
||||
|
||||
const generationId = uuidv4();
|
||||
await makeAPICall("POST", "/api/public/generations", {
|
||||
id: generationId,
|
||||
name: "generation-name",
|
||||
traceId,
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:00:00.000Z",
|
||||
model: "model-name",
|
||||
modelParameters: { key: "value" },
|
||||
prompt: { key: "value" },
|
||||
metadata: { key: "value" },
|
||||
version: "2.0.0",
|
||||
});
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/generations",
|
||||
{
|
||||
id: generationId,
|
||||
name: "generation-name",
|
||||
traceId,
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:00:00.000Z",
|
||||
model: "model-name",
|
||||
modelParameters: { key: "value" },
|
||||
prompt: { key: "value" },
|
||||
metadata: { key: "value" },
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
const dbGeneration = await prisma.observation.findMany({
|
||||
where: {
|
||||
@@ -743,9 +787,14 @@ describe("/api/public/scores API Endpoint", () => {
|
||||
|
||||
const traceId = uuidv4();
|
||||
|
||||
await makeAPICall("POST", "/api/public/traces", {
|
||||
id: traceId,
|
||||
});
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
id: traceId,
|
||||
},
|
||||
);
|
||||
|
||||
const scoreId = uuidv4();
|
||||
const createScore = await makeAPICall("POST", "/api/public/scores", {
|
||||
@@ -783,13 +832,23 @@ describe("/api/public/scores API Endpoint", () => {
|
||||
|
||||
const traceId = uuidv4();
|
||||
|
||||
await makeAPICall("POST", "/api/public/traces", {
|
||||
id: traceId,
|
||||
});
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
id: traceId,
|
||||
},
|
||||
);
|
||||
const generationId = uuidv4();
|
||||
await makeAPICall("POST", "/api/public/generations", {
|
||||
id: generationId,
|
||||
});
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/generations",
|
||||
{
|
||||
id: generationId,
|
||||
},
|
||||
);
|
||||
|
||||
const scoreId = uuidv4();
|
||||
await makeAPICall("POST", "/api/public/scores", {
|
||||
@@ -835,13 +894,23 @@ describe("/api/public/scores API Endpoint", () => {
|
||||
should_prune_db = false;
|
||||
await pruneDatabase();
|
||||
|
||||
await makeAPICall("POST", "/api/public/traces", {
|
||||
id: traceId,
|
||||
userId: userId,
|
||||
});
|
||||
await makeAPICall("POST", "/api/public/generations", {
|
||||
id: generationId,
|
||||
});
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
id: traceId,
|
||||
userId: userId,
|
||||
},
|
||||
);
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/generations",
|
||||
{
|
||||
id: generationId,
|
||||
},
|
||||
);
|
||||
|
||||
await makeAPICall("POST", "/api/public/score-configs", {
|
||||
name: scoreName,
|
||||
@@ -1124,6 +1193,45 @@ describe("/api/public/scores API Endpoint", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("should GET ALL scores with minimal score data and minimal trace data", async () => {
|
||||
const minimalTraceId = uuidv4();
|
||||
await makeAPICall("POST", "/api/public/traces", {
|
||||
id: minimalTraceId,
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
});
|
||||
const dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
id: minimalTraceId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbTrace.length).toBeGreaterThan(0);
|
||||
expect(dbTrace[0]?.id).toBe(minimalTraceId);
|
||||
|
||||
const createScore = await makeAPICall("POST", "/api/public/scores", {
|
||||
name: "score-name",
|
||||
value: 100,
|
||||
traceId: minimalTraceId,
|
||||
});
|
||||
|
||||
expect(createScore.status).toBe(200);
|
||||
|
||||
const fetchedScores = await makeZodVerifiedAPICall(
|
||||
GetScoresResponse,
|
||||
"GET",
|
||||
`/api/public/scores`,
|
||||
);
|
||||
|
||||
expect(fetchedScores.status).toBe(200);
|
||||
expect(fetchedScores.body.meta).toMatchObject({
|
||||
page: 1,
|
||||
limit: 50,
|
||||
totalItems: 4,
|
||||
totalPages: 1,
|
||||
});
|
||||
expect(fetchedScores.body.data.length).toBe(4);
|
||||
});
|
||||
|
||||
it("test invalid operator", async () => {
|
||||
try {
|
||||
await makeZodVerifiedAPICall(
|
||||
|
||||
@@ -1,20 +1,33 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { makeAPICall, pruneDatabase } from "@/src/__tests__/test-utils";
|
||||
import {
|
||||
makeZodVerifiedAPICall,
|
||||
pruneDatabase,
|
||||
} from "@/src/__tests__/test-utils";
|
||||
import {
|
||||
GetSessionV1Response,
|
||||
GetSessionsV1Response,
|
||||
} from "@/src/features/public-api/types/sessions";
|
||||
import { PostTracesV1Response } from "@/src/features/public-api/types/traces";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
|
||||
describe("/api/public/traces API Endpoint", () => {
|
||||
describe("Create and get sessions", () => {
|
||||
beforeEach(async () => await pruneDatabase());
|
||||
afterEach(async () => await pruneDatabase());
|
||||
|
||||
it("should create a session via a trace", async () => {
|
||||
await pruneDatabase();
|
||||
|
||||
await makeAPICall("POST", "/api/public/traces", {
|
||||
name: "trace-name",
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
sessionId: "session-id",
|
||||
});
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
name: "trace-name",
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
sessionId: "session-id",
|
||||
},
|
||||
);
|
||||
|
||||
const dbSession = await prisma.traceSession.findFirst({
|
||||
where: {
|
||||
@@ -32,16 +45,22 @@ describe("/api/public/traces API Endpoint", () => {
|
||||
it("should get session including traces", async () => {
|
||||
await pruneDatabase();
|
||||
|
||||
await makeAPICall("POST", "/api/public/traces", {
|
||||
name: "trace-name",
|
||||
id: "trace-id",
|
||||
input: { hello: "world" },
|
||||
output: "hi",
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
sessionId: "session-id",
|
||||
});
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
name: "trace-name",
|
||||
id: "trace-id",
|
||||
input: { hello: "world" },
|
||||
output: "hi",
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
sessionId: "session-id",
|
||||
},
|
||||
);
|
||||
|
||||
const response = await makeAPICall(
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
GetSessionV1Response,
|
||||
"GET",
|
||||
"/api/public/sessions/session-id",
|
||||
);
|
||||
@@ -60,3 +79,122 @@ describe("/api/public/traces API Endpoint", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/public/sessions API Endpoint", () => {
|
||||
beforeEach(async () => {
|
||||
await pruneDatabase();
|
||||
await prisma.traceSession.createMany({
|
||||
data: [
|
||||
{
|
||||
id: "session-2021-01-01",
|
||||
createdAt: new Date("2021-01-01T00:00:00Z"),
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
},
|
||||
{
|
||||
id: "session-2021-02-01",
|
||||
createdAt: new Date("2021-02-01T00:00:00Z"),
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
},
|
||||
{
|
||||
id: "session-2021-03-01",
|
||||
createdAt: new Date("2021-03-01T00:00:00Z"),
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
},
|
||||
{
|
||||
id: "session-2021-04-01",
|
||||
createdAt: new Date("2021-04-01T00:00:00Z"),
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
},
|
||||
{
|
||||
id: "session-2021-05-01",
|
||||
createdAt: new Date("2021-05-01T00:00:00Z"),
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
afterEach(async () => await pruneDatabase());
|
||||
|
||||
it("should return all sessions", async () => {
|
||||
const sessions = await makeZodVerifiedAPICall(
|
||||
GetSessionsV1Response,
|
||||
"GET",
|
||||
"/api/public/sessions",
|
||||
);
|
||||
expect(sessions.body.data).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("should return paginated sessions with limit and page number", async () => {
|
||||
const limit = 2;
|
||||
const page = 2;
|
||||
|
||||
const sessions = await makeZodVerifiedAPICall(
|
||||
GetSessionsV1Response,
|
||||
"GET",
|
||||
`/api/public/sessions?limit=${limit}&page=${page}`,
|
||||
);
|
||||
|
||||
expect(sessions.body.data).toHaveLength(2);
|
||||
expect(sessions.body.data.map((session) => session.id)).toEqual([
|
||||
"session-2021-03-01",
|
||||
"session-2021-02-01",
|
||||
]);
|
||||
expect(sessions.body.meta.totalItems).toBe(5);
|
||||
expect(sessions.body.meta.totalPages).toBe(3);
|
||||
expect(sessions.body.meta.page).toBe(page);
|
||||
expect(sessions.body.meta.limit).toBe(limit);
|
||||
});
|
||||
|
||||
it("should return sessions within a specific date range", async () => {
|
||||
const fromTimestamp = "2021-02-01T00:00:00Z";
|
||||
const toTimestamp = "2021-04-01T00:00:00Z";
|
||||
|
||||
const sessions = await makeZodVerifiedAPICall(
|
||||
GetSessionsV1Response,
|
||||
"GET",
|
||||
`/api/public/sessions?fromTimestamp=${fromTimestamp}&toTimestamp=${toTimestamp}`,
|
||||
);
|
||||
|
||||
expect(sessions.body.data).toHaveLength(2);
|
||||
expect(sessions.body.data.map((session) => session.id)).toEqual([
|
||||
"session-2021-03-01",
|
||||
"session-2021-02-01",
|
||||
]);
|
||||
expect(sessions.body.meta.totalItems).toBe(2);
|
||||
});
|
||||
|
||||
it("should return sessions from a specific date onwards (including the date)", async () => {
|
||||
const fromTimestamp = "2021-03-01T00:00:00Z";
|
||||
|
||||
const sessions = await makeZodVerifiedAPICall(
|
||||
GetSessionsV1Response,
|
||||
"GET",
|
||||
`/api/public/sessions?fromTimestamp=${fromTimestamp}`,
|
||||
);
|
||||
|
||||
expect(sessions.body.data).toHaveLength(3);
|
||||
expect(sessions.body.data.map((session) => session.id)).toEqual([
|
||||
"session-2021-05-01",
|
||||
"session-2021-04-01",
|
||||
"session-2021-03-01",
|
||||
]);
|
||||
expect(sessions.body.meta.totalItems).toBe(3);
|
||||
});
|
||||
|
||||
it("should return sessions up to a specific date (excluding it)", async () => {
|
||||
const toTimestamp = "2021-03-01T00:00:00Z";
|
||||
|
||||
const sessions = await makeZodVerifiedAPICall(
|
||||
GetSessionsV1Response,
|
||||
"GET",
|
||||
`/api/public/sessions?toTimestamp=${toTimestamp}`,
|
||||
);
|
||||
|
||||
expect(sessions.body.data).toHaveLength(2);
|
||||
expect(sessions.body.data.map((session) => session.id)).toEqual([
|
||||
"session-2021-02-01",
|
||||
"session-2021-01-01",
|
||||
]);
|
||||
expect(sessions.body.meta.totalItems).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,27 +1,39 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { makeAPICall, pruneDatabase } from "@/src/__tests__/test-utils";
|
||||
import {
|
||||
makeAPICall,
|
||||
makeZodVerifiedAPICall,
|
||||
pruneDatabase,
|
||||
} from "@/src/__tests__/test-utils";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { PostTracesV1Response } from "@/src/features/public-api/types/traces";
|
||||
import {
|
||||
PatchSpansV1Response,
|
||||
PostSpansV1Response,
|
||||
} from "@/src/features/public-api/types/spans";
|
||||
|
||||
describe("/api/public/spans API Endpoint", () => {
|
||||
beforeEach(async () => await pruneDatabase());
|
||||
afterEach(async () => await pruneDatabase());
|
||||
|
||||
it("should create span after trace", async () => {
|
||||
await pruneDatabase();
|
||||
|
||||
const traceId = uuidv4();
|
||||
|
||||
await makeAPICall("POST", "/api/public/traces", {
|
||||
id: traceId,
|
||||
name: "trace-name",
|
||||
userId: "user-1",
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
});
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
id: traceId,
|
||||
name: "trace-name",
|
||||
userId: "user-1",
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
const dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
@@ -33,16 +45,21 @@ describe("/api/public/spans API Endpoint", () => {
|
||||
expect(dbTrace[0]?.id).toBe(traceId);
|
||||
|
||||
const spanId = uuidv4();
|
||||
const createSpan = await makeAPICall("POST", "/api/public/spans", {
|
||||
id: spanId,
|
||||
traceId: traceId,
|
||||
name: "span-name",
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:00:00.000Z",
|
||||
input: { input: "value" },
|
||||
metadata: { meta: "value" },
|
||||
version: "2.0.0",
|
||||
});
|
||||
const createSpan = await makeZodVerifiedAPICall(
|
||||
PostSpansV1Response,
|
||||
"POST",
|
||||
"/api/public/spans",
|
||||
{
|
||||
id: spanId,
|
||||
traceId: traceId,
|
||||
name: "span-name",
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:00:00.000Z",
|
||||
input: { input: "value" },
|
||||
metadata: { meta: "value" },
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
expect(createSpan.status).toBe(200);
|
||||
const dbSpan = await prisma.observation.findUnique({
|
||||
@@ -62,21 +79,24 @@ describe("/api/public/spans API Endpoint", () => {
|
||||
});
|
||||
|
||||
it("should create span before trace", async () => {
|
||||
await pruneDatabase();
|
||||
|
||||
const traceId = uuidv4();
|
||||
const spanId = uuidv4();
|
||||
|
||||
const createSpan = await makeAPICall("POST", "/api/public/spans", {
|
||||
id: spanId,
|
||||
traceId: traceId,
|
||||
name: "span-name",
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:00:00.000Z",
|
||||
input: { input: "value" },
|
||||
metadata: { meta: "value" },
|
||||
version: "2.0.0",
|
||||
});
|
||||
const createSpan = await makeZodVerifiedAPICall(
|
||||
PostSpansV1Response,
|
||||
"POST",
|
||||
"/api/public/spans",
|
||||
{
|
||||
id: spanId,
|
||||
traceId: traceId,
|
||||
name: "span-name",
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:00:00.000Z",
|
||||
input: { input: "value" },
|
||||
metadata: { meta: "value" },
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
expect(createSpan.status).toBe(200);
|
||||
const dbSpan = await prisma.observation.findUnique({
|
||||
@@ -94,15 +114,20 @@ describe("/api/public/spans API Endpoint", () => {
|
||||
expect(dbSpan?.metadata).toEqual({ meta: "value" });
|
||||
expect(dbSpan?.version).toBe("2.0.0");
|
||||
|
||||
await makeAPICall("POST", "/api/public/traces", {
|
||||
id: traceId,
|
||||
name: "trace-name",
|
||||
userId: "user-1",
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
});
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
id: traceId,
|
||||
name: "trace-name",
|
||||
userId: "user-1",
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
const dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
@@ -118,21 +143,31 @@ describe("/api/public/spans API Endpoint", () => {
|
||||
const traceId = uuidv4();
|
||||
const spanId = uuidv4();
|
||||
|
||||
const response = await makeAPICall("POST", "/api/public/traces", {
|
||||
externalId: uuidv4(),
|
||||
id: traceId,
|
||||
name: "trace-name",
|
||||
userId: "user-1",
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
});
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
externalId: uuidv4(),
|
||||
id: traceId,
|
||||
name: "trace-name",
|
||||
userId: "user-1",
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
},
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const createSpan = await makeAPICall("POST", "/api/public/spans", {
|
||||
id: spanId,
|
||||
name: "spanName",
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
traceId: traceId,
|
||||
externalTraceIdType: "EXTERNAL",
|
||||
});
|
||||
const createSpan = await makeZodVerifiedAPICall(
|
||||
PostSpansV1Response,
|
||||
"POST",
|
||||
"/api/public/spans",
|
||||
{
|
||||
id: spanId,
|
||||
name: "spanName",
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
traceId: traceId,
|
||||
externalTraceIdType: "EXTERNAL",
|
||||
},
|
||||
);
|
||||
expect(createSpan.status).toBe(200);
|
||||
|
||||
const dbSpan = await prisma.observation.findFirstOrThrow({
|
||||
@@ -148,15 +183,20 @@ describe("/api/public/spans API Endpoint", () => {
|
||||
const spanName = uuidv4();
|
||||
|
||||
const spanId = uuidv4();
|
||||
const createSpan = await makeAPICall("POST", "/api/public/spans", {
|
||||
id: spanId,
|
||||
name: spanName,
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:00:00.000Z",
|
||||
input: { input: "value" },
|
||||
metadata: { meta: "value" },
|
||||
version: "2.0.0",
|
||||
});
|
||||
const createSpan = await makeZodVerifiedAPICall(
|
||||
PostSpansV1Response,
|
||||
"POST",
|
||||
"/api/public/spans",
|
||||
{
|
||||
id: spanId,
|
||||
name: spanName,
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:00:00.000Z",
|
||||
input: { input: "value" },
|
||||
metadata: { meta: "value" },
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
const dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
@@ -189,17 +229,22 @@ describe("/api/public/spans API Endpoint", () => {
|
||||
|
||||
const spanId = uuidv4();
|
||||
const traceId = uuidv4();
|
||||
const createSpan = await makeAPICall("POST", "/api/public/spans", {
|
||||
id: spanId,
|
||||
traceIdType: "EXTERNAL",
|
||||
traceId: traceId,
|
||||
name: spanName,
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:00:00.000Z",
|
||||
input: { input: "value" },
|
||||
metadata: { meta: "value" },
|
||||
version: "2.0.0",
|
||||
});
|
||||
const createSpan = await makeZodVerifiedAPICall(
|
||||
PostSpansV1Response,
|
||||
"POST",
|
||||
"/api/public/spans",
|
||||
{
|
||||
id: spanId,
|
||||
traceIdType: "EXTERNAL",
|
||||
traceId: traceId,
|
||||
name: spanName,
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:00:00.000Z",
|
||||
input: { input: "value" },
|
||||
metadata: { meta: "value" },
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
expect(createSpan.status).toBe(200);
|
||||
|
||||
@@ -221,15 +266,20 @@ describe("/api/public/spans API Endpoint", () => {
|
||||
const generationName = uuidv4();
|
||||
|
||||
const spanId = uuidv4();
|
||||
const createSpan = await makeAPICall("POST", "/api/public/spans", {
|
||||
id: spanId,
|
||||
name: generationName,
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:00:00.000Z",
|
||||
input: { key: "value" },
|
||||
metadata: { key: "value" },
|
||||
version: "2.0.0",
|
||||
});
|
||||
const createSpan = await makeZodVerifiedAPICall(
|
||||
PostSpansV1Response,
|
||||
"POST",
|
||||
"/api/public/spans",
|
||||
{
|
||||
id: spanId,
|
||||
name: generationName,
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:00:00.000Z",
|
||||
input: { key: "value" },
|
||||
metadata: { key: "value" },
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
const dbSpan = await prisma.observation.findFirstOrThrow({
|
||||
where: {
|
||||
@@ -262,22 +312,32 @@ describe("/api/public/spans API Endpoint", () => {
|
||||
const spanName = uuidv4();
|
||||
|
||||
const spanId = uuidv4();
|
||||
const createSpan = await makeAPICall("POST", "/api/public/spans", {
|
||||
id: spanId,
|
||||
name: spanName,
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:00:00.000Z",
|
||||
input: { input: "value" },
|
||||
metadata: { meta: "value" },
|
||||
version: "2.0.0",
|
||||
});
|
||||
const createSpan = await makeZodVerifiedAPICall(
|
||||
PostSpansV1Response,
|
||||
"POST",
|
||||
"/api/public/spans",
|
||||
{
|
||||
id: spanId,
|
||||
name: spanName,
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:00:00.000Z",
|
||||
input: { input: "value" },
|
||||
metadata: { meta: "value" },
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
expect(createSpan.status).toBe(200);
|
||||
|
||||
const updatedSpan = await makeAPICall("PATCH", "/api/public/spans", {
|
||||
spanId: spanId,
|
||||
output: { key: "this is a great gpt output" },
|
||||
});
|
||||
const updatedSpan = await makeZodVerifiedAPICall(
|
||||
PatchSpansV1Response,
|
||||
"PATCH",
|
||||
"/api/public/spans",
|
||||
{
|
||||
spanId: spanId,
|
||||
output: { key: "this is a great gpt output" },
|
||||
},
|
||||
);
|
||||
expect(updatedSpan.status).toBe(200);
|
||||
|
||||
const dbSpan = await prisma.observation.findUnique({
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export default async function teardown() {
|
||||
const { redis } = await import("@langfuse/shared/src/server");
|
||||
console.log(`Redis status ${redis?.status}`);
|
||||
if (!redis) {
|
||||
return;
|
||||
}
|
||||
if (redis.status === "end" || redis.status === "close") {
|
||||
console.log("Redis connection already closed");
|
||||
return;
|
||||
}
|
||||
redis?.disconnect();
|
||||
console.log("Teardown complete");
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { hash } from "bcryptjs";
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { getDisplaySecretKey, hashSecretKey } from "@langfuse/shared";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { type z, ZodObject } from "zod";
|
||||
import { type z } from "zod";
|
||||
|
||||
export const pruneDatabase = async () => {
|
||||
if (!env.DATABASE_URL.includes("localhost:5432")) {
|
||||
@@ -14,6 +14,7 @@ export const pruneDatabase = async () => {
|
||||
await prisma.scoreConfig.deleteMany();
|
||||
await prisma.observation.deleteMany();
|
||||
await prisma.trace.deleteMany();
|
||||
await prisma.traceSession.deleteMany();
|
||||
await prisma.datasetItem.deleteMany();
|
||||
await prisma.dataset.deleteMany();
|
||||
await prisma.datasetRuns.deleteMany();
|
||||
@@ -87,16 +88,11 @@ export async function makeZodVerifiedAPICall<T extends z.ZodTypeAny>(
|
||||
`API call did not return 200, returned status ${status}, body ${JSON.stringify(resBody)}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
if (responseZodSchema instanceof ZodObject) {
|
||||
responseZodSchema.strict().parse(resBody);
|
||||
} else {
|
||||
responseZodSchema.parse(resBody);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
const typeCheckResult = responseZodSchema.safeParse(resBody);
|
||||
if (!typeCheckResult.success) {
|
||||
console.error(typeCheckResult.error);
|
||||
throw new Error(
|
||||
`API call (${method} ${url}) did not return valid response, returned status ${status}, body ${JSON.stringify(resBody)}, error ${e}`,
|
||||
`API call (${method} ${url}) did not return valid response, returned status ${status}, body ${JSON.stringify(resBody)}, error ${typeCheckResult.error}`,
|
||||
);
|
||||
}
|
||||
return { body: resBody, status };
|
||||
|
||||
@@ -126,30 +126,119 @@ describe("Token Cost Calculation", () => {
|
||||
|
||||
it("should correctly calculate token costs when only some user provided costs are given", async () => {
|
||||
const model = {
|
||||
inputPrice: new Decimal(0.01),
|
||||
outputPrice: new Decimal(0.02),
|
||||
totalPrice: new Decimal(0.03),
|
||||
inputPrice: new Decimal(1),
|
||||
outputPrice: new Decimal(1),
|
||||
};
|
||||
|
||||
const tokenCounts = {
|
||||
input: 100,
|
||||
output: 200,
|
||||
total: undefined,
|
||||
};
|
||||
const data = [
|
||||
// missing total
|
||||
{
|
||||
userProvidedCosts: {
|
||||
input: 1,
|
||||
output: 2,
|
||||
total: undefined,
|
||||
},
|
||||
expectedCost: {
|
||||
input: 1,
|
||||
output: 2,
|
||||
total: 3,
|
||||
},
|
||||
},
|
||||
// only total
|
||||
{
|
||||
userProvidedCosts: {
|
||||
input: undefined,
|
||||
output: undefined,
|
||||
total: 2,
|
||||
},
|
||||
expectedCost: {
|
||||
input: undefined,
|
||||
output: undefined,
|
||||
total: 2,
|
||||
},
|
||||
},
|
||||
// missing input
|
||||
{
|
||||
userProvidedCosts: {
|
||||
input: undefined,
|
||||
output: 2,
|
||||
total: 2,
|
||||
},
|
||||
expectedCost: {
|
||||
input: undefined,
|
||||
output: 2,
|
||||
total: 2,
|
||||
},
|
||||
},
|
||||
// only input
|
||||
{
|
||||
userProvidedCosts: {
|
||||
input: 1,
|
||||
output: undefined,
|
||||
total: undefined,
|
||||
},
|
||||
expectedCost: {
|
||||
input: 1,
|
||||
output: undefined,
|
||||
total: 1,
|
||||
},
|
||||
},
|
||||
|
||||
const userProvidedCosts = {
|
||||
outputCost: new Decimal(3.0),
|
||||
};
|
||||
// missing output
|
||||
{
|
||||
userProvidedCosts: {
|
||||
input: 1,
|
||||
output: undefined,
|
||||
total: 1,
|
||||
},
|
||||
expectedCost: {
|
||||
input: 1,
|
||||
output: undefined,
|
||||
total: 1,
|
||||
},
|
||||
},
|
||||
|
||||
const costs = ObservationProcessor.calculateTokenCosts(
|
||||
model as any,
|
||||
userProvidedCosts,
|
||||
tokenCounts,
|
||||
);
|
||||
// only output
|
||||
{
|
||||
userProvidedCosts: {
|
||||
input: undefined,
|
||||
output: 2,
|
||||
total: undefined,
|
||||
},
|
||||
expectedCost: {
|
||||
input: undefined,
|
||||
output: 2,
|
||||
total: 2,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
expect(costs.inputCost).toBe(undefined); // No user provided cost
|
||||
expect(costs.outputCost?.toNumber()).toBe(3.0); // Overridden by user provided cost
|
||||
expect(costs.totalCost).toBe(undefined); // No user provided cost
|
||||
for (const { userProvidedCosts, expectedCost } of data) {
|
||||
const tokenCounts = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
const userProvidedCostsDecimal = {
|
||||
inputCost:
|
||||
userProvidedCosts.input && new Decimal(userProvidedCosts.input),
|
||||
outputCost:
|
||||
userProvidedCosts.output && new Decimal(userProvidedCosts.output),
|
||||
totalCost:
|
||||
userProvidedCosts.total && new Decimal(userProvidedCosts.total),
|
||||
};
|
||||
|
||||
const costs = ObservationProcessor.calculateTokenCosts(
|
||||
model as any,
|
||||
userProvidedCostsDecimal as any,
|
||||
tokenCounts,
|
||||
);
|
||||
|
||||
expect(costs.inputCost?.toNumber()).toBe(expectedCost.input);
|
||||
expect(costs.outputCost?.toNumber()).toBe(expectedCost.output);
|
||||
expect(costs.totalCost?.toNumber()).toBe(expectedCost.total);
|
||||
}
|
||||
});
|
||||
|
||||
it("should return empty costs if no model is provided", async () => {
|
||||
@@ -911,7 +1000,7 @@ describe("Token Cost Calculation", () => {
|
||||
expect(generation?.calculatedOutputCost?.toNumber()).toBe(
|
||||
generationUsage2.usage.outputCost,
|
||||
);
|
||||
expect(generation?.calculatedTotalCost?.toNumber()).toBe(undefined);
|
||||
expect(generation?.calculatedTotalCost?.toNumber()).toBe(1);
|
||||
expect(generation?.promptTokens).toBe(generationUsage1.usage.input);
|
||||
expect(generation?.completionTokens).toBe(generationUsage1.usage.output);
|
||||
expect(generation?.totalTokens).toBe(generationUsage1.usage.total);
|
||||
|
||||
@@ -1,58 +1,70 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { makeAPICall, pruneDatabase } from "@/src/__tests__/test-utils";
|
||||
import {
|
||||
makeZodVerifiedAPICall,
|
||||
pruneDatabase,
|
||||
} from "@/src/__tests__/test-utils";
|
||||
import { PostGenerationsV1Response } from "@/src/features/public-api/types/generations";
|
||||
import { PostScoresResponse } from "@/src/features/public-api/types/scores";
|
||||
import {
|
||||
GetTraceV1Response,
|
||||
GetTracesV1Response,
|
||||
PostTracesV1Response,
|
||||
} from "@/src/features/public-api/types/traces";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
interface GetTracesAPIResponse {
|
||||
data: Array<{
|
||||
id: string;
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
}
|
||||
|
||||
describe("/api/public/traces API Endpoint", () => {
|
||||
beforeEach(async () => await pruneDatabase());
|
||||
afterEach(async () => await pruneDatabase());
|
||||
|
||||
it("should create", async () => {
|
||||
it("should create and get a trace via /traces", async () => {
|
||||
await pruneDatabase();
|
||||
|
||||
await makeAPICall("POST", "/api/public/traces", {
|
||||
name: "trace-name",
|
||||
userId: "user-1",
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
});
|
||||
|
||||
const dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
const traceCreate = await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
name: "trace-name",
|
||||
userId: "user-1",
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
expect(dbTrace.length).toBeGreaterThan(0);
|
||||
expect(dbTrace[0]?.name).toBe("trace-name");
|
||||
expect(dbTrace[0]?.release).toBe("1.0.0");
|
||||
expect(dbTrace[0]?.externalId).toBeNull();
|
||||
expect(dbTrace[0]?.version).toBe("2.0.0");
|
||||
expect(dbTrace[0]?.projectId).toBe("7a88fb47-b4e2-43b8-a06c-a5ce950dc53a");
|
||||
const trace = await makeZodVerifiedAPICall(
|
||||
GetTraceV1Response,
|
||||
"GET",
|
||||
"/api/public/traces/" + traceCreate.body.id,
|
||||
);
|
||||
|
||||
expect(trace.body.name).toBe("trace-name");
|
||||
expect(trace.body.release).toBe("1.0.0");
|
||||
expect(trace.body.externalId).toBeNull();
|
||||
expect(trace.body.version).toBe("2.0.0");
|
||||
expect(trace.body.projectId).toBe("7a88fb47-b4e2-43b8-a06c-a5ce950dc53a");
|
||||
});
|
||||
|
||||
it("should upsert second trace", async () => {
|
||||
await pruneDatabase();
|
||||
|
||||
await makeAPICall("POST", "/api/public/traces", {
|
||||
id: "trace-id",
|
||||
name: "trace-name",
|
||||
userId: "user-1",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
public: true,
|
||||
});
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
id: "trace-id",
|
||||
name: "trace-name",
|
||||
userId: "user-1",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
public: true,
|
||||
},
|
||||
);
|
||||
|
||||
const dbTrace1 = await prisma.trace.findFirst({
|
||||
where: {
|
||||
@@ -70,14 +82,19 @@ describe("/api/public/traces API Endpoint", () => {
|
||||
userId: "user-1",
|
||||
});
|
||||
|
||||
await makeAPICall("POST", "/api/public/traces", {
|
||||
id: "trace-id",
|
||||
metadata: { key: "value" },
|
||||
timestamp: "2021-01-01T00:00:00.000Z",
|
||||
release: "1.0.0",
|
||||
version: "5.0.0",
|
||||
public: false,
|
||||
});
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
id: "trace-id",
|
||||
metadata: { key: "value" },
|
||||
timestamp: "2021-01-01T00:00:00.000Z",
|
||||
release: "1.0.0",
|
||||
version: "5.0.0",
|
||||
public: false,
|
||||
},
|
||||
);
|
||||
|
||||
const dbTrace2 = await prisma.trace.findFirst({
|
||||
where: {
|
||||
@@ -100,23 +117,39 @@ describe("/api/public/traces API Endpoint", () => {
|
||||
it("should use tags correctly on POST and GET", async () => {
|
||||
await pruneDatabase();
|
||||
|
||||
await makeAPICall("POST", "/api/public/traces", {
|
||||
id: "trace-1",
|
||||
tags: ["tag-1", "tag-2", "tag-3"],
|
||||
});
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
id: "trace-1",
|
||||
tags: ["tag-1", "tag-2", "tag-3"],
|
||||
},
|
||||
);
|
||||
|
||||
await makeAPICall("POST", "/api/public/traces", {
|
||||
id: "trace-2",
|
||||
tags: ["tag-1"],
|
||||
});
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
id: "trace-2",
|
||||
tags: ["tag-1"],
|
||||
},
|
||||
);
|
||||
|
||||
await makeAPICall("POST", "/api/public/traces", {
|
||||
id: "trace-3",
|
||||
tags: ["tag-2", "tag-3"],
|
||||
});
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
id: "trace-3",
|
||||
tags: ["tag-2", "tag-3"],
|
||||
},
|
||||
);
|
||||
|
||||
// multiple tags
|
||||
const traces = await makeAPICall<GetTracesAPIResponse>(
|
||||
const traces = await makeZodVerifiedAPICall(
|
||||
GetTracesV1Response,
|
||||
"GET",
|
||||
"/api/public/traces?tags=tag-2&tags=tag-3",
|
||||
);
|
||||
@@ -125,7 +158,8 @@ describe("/api/public/traces API Endpoint", () => {
|
||||
expect(traceIds).toEqual(["trace-3", "trace-1"]);
|
||||
|
||||
// single tag
|
||||
const traces2 = await makeAPICall<GetTracesAPIResponse>(
|
||||
const traces2 = await makeZodVerifiedAPICall(
|
||||
GetTracesV1Response,
|
||||
"GET",
|
||||
"/api/public/traces?tags=tag-1",
|
||||
);
|
||||
@@ -134,7 +168,8 @@ describe("/api/public/traces API Endpoint", () => {
|
||||
expect(traceIds2).toEqual(["trace-2", "trace-1"]);
|
||||
|
||||
// wrong tag
|
||||
const traces3 = await makeAPICall<GetTracesAPIResponse>(
|
||||
const traces3 = await makeZodVerifiedAPICall(
|
||||
GetTracesV1Response,
|
||||
"GET",
|
||||
"/api/public/traces?tags=tag-10",
|
||||
);
|
||||
@@ -143,7 +178,8 @@ describe("/api/public/traces API Endpoint", () => {
|
||||
expect(traceIds3).toEqual([]);
|
||||
|
||||
// no tag
|
||||
const traces4 = await makeAPICall<GetTracesAPIResponse>(
|
||||
const traces4 = await makeZodVerifiedAPICall(
|
||||
GetTracesV1Response,
|
||||
"GET",
|
||||
"/api/public/traces?tags=",
|
||||
);
|
||||
@@ -157,60 +193,94 @@ describe("/api/public/traces API Endpoint", () => {
|
||||
|
||||
// Create a trace with some observations that have costs and latencies
|
||||
const traceId = uuidv4();
|
||||
await makeAPICall("POST", "/api/public/traces", {
|
||||
id: traceId,
|
||||
name: "trace-with-costs",
|
||||
userId: "user-costs",
|
||||
projectId: "project-costs",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
});
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
id: traceId,
|
||||
name: "trace-with-costs",
|
||||
userId: "user-costs",
|
||||
projectId: "project-costs",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
console.log(traceId);
|
||||
|
||||
// Simulate observations with costs and latencies
|
||||
await makeAPICall("POST", "/api/public/generations", {
|
||||
traceId: traceId,
|
||||
usage: { totalCost: 10.5 },
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:10:00.000Z",
|
||||
});
|
||||
await makeAPICall("POST", "/api/public/generations", {
|
||||
traceId: traceId,
|
||||
usage: { totalCost: 5.25 },
|
||||
startTime: "2021-01-01T00:10:00.000Z",
|
||||
endTime: "2021-01-01T00:20:00.000Z",
|
||||
});
|
||||
const generationId = uuidv4();
|
||||
await makeZodVerifiedAPICall(
|
||||
PostGenerationsV1Response,
|
||||
"POST",
|
||||
"/api/public/generations",
|
||||
{
|
||||
traceId: traceId,
|
||||
id: generationId,
|
||||
name: "Generation1",
|
||||
usage: { totalCost: 10.5 },
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:10:00.000Z",
|
||||
},
|
||||
);
|
||||
await makeZodVerifiedAPICall(
|
||||
PostGenerationsV1Response,
|
||||
"POST",
|
||||
"/api/public/generations",
|
||||
{
|
||||
traceId: traceId,
|
||||
usage: { totalCost: 5.25 },
|
||||
startTime: "2021-01-01T00:10:00.000Z",
|
||||
endTime: "2021-01-01T00:20:00.000Z",
|
||||
},
|
||||
);
|
||||
|
||||
// Simulate scores on the trace
|
||||
const scoreId1 = uuidv4();
|
||||
await makeAPICall("POST", "/api/public/scores", {
|
||||
id: scoreId1,
|
||||
name: "score-1",
|
||||
value: 75.0,
|
||||
traceId: traceId,
|
||||
comment: "First score",
|
||||
});
|
||||
await makeZodVerifiedAPICall(
|
||||
PostScoresResponse,
|
||||
"POST",
|
||||
"/api/public/scores",
|
||||
{
|
||||
id: scoreId1,
|
||||
name: "score-1",
|
||||
value: 75.0,
|
||||
traceId: traceId,
|
||||
comment: "First score",
|
||||
},
|
||||
);
|
||||
const scoreId2 = uuidv4();
|
||||
await makeAPICall("POST", "/api/public/scores", {
|
||||
id: scoreId2,
|
||||
name: "score-2",
|
||||
value: 85.5,
|
||||
traceId: traceId,
|
||||
comment: "Second score",
|
||||
});
|
||||
await makeZodVerifiedAPICall(
|
||||
PostScoresResponse,
|
||||
"POST",
|
||||
"/api/public/scores",
|
||||
{
|
||||
id: scoreId2,
|
||||
name: "score-2",
|
||||
value: 85.5,
|
||||
traceId: traceId,
|
||||
comment: "Second score",
|
||||
},
|
||||
);
|
||||
const scoreId3 = uuidv4();
|
||||
await makeAPICall("POST", "/api/public/scores", {
|
||||
id: scoreId3,
|
||||
name: "score-3",
|
||||
value: 95.0,
|
||||
traceId: traceId,
|
||||
comment: "Third score",
|
||||
});
|
||||
await makeZodVerifiedAPICall(
|
||||
PostScoresResponse,
|
||||
"POST",
|
||||
"/api/public/scores",
|
||||
{
|
||||
id: scoreId3,
|
||||
name: "score-3",
|
||||
value: 95.0,
|
||||
traceId: traceId,
|
||||
comment: "Third score",
|
||||
},
|
||||
);
|
||||
|
||||
// GET traces
|
||||
// Retrieve the trace with totalCost and latency
|
||||
const traces = await makeAPICall<GetTracesAPIResponse>(
|
||||
const traces = await makeZodVerifiedAPICall(
|
||||
GetTracesV1Response,
|
||||
"GET",
|
||||
`/api/public/traces`,
|
||||
);
|
||||
@@ -226,17 +296,23 @@ describe("/api/public/traces API Endpoint", () => {
|
||||
|
||||
// GET trace
|
||||
// Retrieve the trace with total
|
||||
const trace = await makeAPICall<{
|
||||
id: string;
|
||||
totalCost: number;
|
||||
htmlPath: string;
|
||||
}>("GET", `/api/public/traces/${traceId}`);
|
||||
const trace = await makeZodVerifiedAPICall(
|
||||
GetTraceV1Response,
|
||||
"GET",
|
||||
`/api/public/traces/${traceId}`,
|
||||
);
|
||||
console.log(trace.body);
|
||||
expect(trace.body.totalCost).toBeCloseTo(15.75);
|
||||
expect(trace.body.id).toBe(traceId);
|
||||
expect(trace.body.id).toBe(traceId);
|
||||
expect(trace.body.htmlPath).toContain(`/traces/${traceId}`);
|
||||
expect(trace.body.htmlPath).toContain(`/project/`); // do not know the projectId
|
||||
expect(trace.body.scores).toHaveLength(3);
|
||||
expect(trace.body.scores[0].id).toBe(scoreId3);
|
||||
expect(trace.body.scores[0].name).toBe("score-3");
|
||||
expect(trace.body.observations).toHaveLength(2);
|
||||
expect(trace.body.observations[0].id).toBe(generationId);
|
||||
expect(trace.body.observations[0].name).toBe("Generation1");
|
||||
});
|
||||
|
||||
it("should filter traces by session ID", async () => {
|
||||
@@ -244,30 +320,41 @@ describe("/api/public/traces API Endpoint", () => {
|
||||
const anotherSessionId = "another-session-id";
|
||||
|
||||
// Create traces with different session IDs
|
||||
await makeAPICall("POST", "/api/public/traces", {
|
||||
id: "trace-1",
|
||||
name: "test-trace-1",
|
||||
sessionId,
|
||||
userId: "user-1",
|
||||
projectId: "project-1",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "1.0.0",
|
||||
});
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
id: "trace-1",
|
||||
name: "test-trace-1",
|
||||
sessionId,
|
||||
userId: "user-1",
|
||||
projectId: "project-1",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "1.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
await makeAPICall("POST", "/api/public/traces", {
|
||||
id: "trace-2",
|
||||
name: "test-trace-2",
|
||||
sessionId: anotherSessionId,
|
||||
userId: "user-2",
|
||||
projectId: "project-1",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "1.0.0",
|
||||
});
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
id: "trace-2",
|
||||
name: "test-trace-2",
|
||||
sessionId: anotherSessionId,
|
||||
userId: "user-2",
|
||||
projectId: "project-1",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "1.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
// Filter by session ID
|
||||
const tracesBySessionId = await makeAPICall<GetTracesAPIResponse>(
|
||||
const tracesBySessionId = await makeZodVerifiedAPICall(
|
||||
GetTracesV1Response,
|
||||
"GET",
|
||||
`/api/public/traces?sessionId=${sessionId}`,
|
||||
);
|
||||
@@ -277,7 +364,8 @@ describe("/api/public/traces API Endpoint", () => {
|
||||
expect(tracesBySessionId.body.data[0].id).toBe("trace-1");
|
||||
|
||||
// Filter by another session ID
|
||||
const tracesByAnotherSessionId = await makeAPICall<GetTracesAPIResponse>(
|
||||
const tracesByAnotherSessionId = await makeZodVerifiedAPICall(
|
||||
GetTracesV1Response,
|
||||
"GET",
|
||||
`/api/public/traces?sessionId=${anotherSessionId}`,
|
||||
);
|
||||
@@ -287,11 +375,11 @@ describe("/api/public/traces API Endpoint", () => {
|
||||
expect(tracesByAnotherSessionId.body.data[0].id).toBe("trace-2");
|
||||
|
||||
// Filter by non-existent session ID
|
||||
const tracesByNonExistentSessionId =
|
||||
await makeAPICall<GetTracesAPIResponse>(
|
||||
"GET",
|
||||
`/api/public/traces?sessionId=non-existent-session-id`,
|
||||
);
|
||||
const tracesByNonExistentSessionId = await makeZodVerifiedAPICall(
|
||||
GetTracesV1Response,
|
||||
"GET",
|
||||
`/api/public/traces?sessionId=non-existent-session-id`,
|
||||
);
|
||||
|
||||
expect(tracesByNonExistentSessionId.status).toBe(200);
|
||||
expect(tracesByNonExistentSessionId.body.data).toHaveLength(0);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ModelUsageUnit } from "@langfuse/shared";
|
||||
import { tokenCount } from "@/src/features/ingest/lib/usage";
|
||||
import { tokenCount } from "@/src/features/ingest/usage";
|
||||
|
||||
describe("Token Count Functions", () => {
|
||||
const generateModel = (model: string, tokenizer: string) => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import React from "react";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { showSuccessToast } from "@/src/features/notifications/showSuccessToast";
|
||||
import { useHasAccess } from "@/src/features/rbac/utils/checkAccess";
|
||||
import { useIsEeEnabled } from "@/src/ee/utils/useIsEeEnabled";
|
||||
|
||||
export type BatchExportTableButtonProps = {
|
||||
@@ -31,6 +32,10 @@ export const BatchExportTableButton: React.FC<BatchExportTableButtonProps> = (
|
||||
const [isExporting, setIsExporting] = React.useState(false);
|
||||
const createExport = api.batchExport.create.useMutation();
|
||||
const isEeEnabled = useIsEeEnabled();
|
||||
const hasAccess = useHasAccess({
|
||||
projectId: props.projectId,
|
||||
scope: "batchExport:create",
|
||||
});
|
||||
|
||||
const handleExport = async (format: BatchExportFileFormat) => {
|
||||
setIsExporting(true);
|
||||
@@ -51,7 +56,7 @@ export const BatchExportTableButton: React.FC<BatchExportTableButtonProps> = (
|
||||
});
|
||||
};
|
||||
|
||||
if (!isEeEnabled) return null;
|
||||
if (!isEeEnabled || !hasAccess) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -11,29 +11,18 @@ import {
|
||||
} from "@/src/components/ui/popover";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { type DateRange } from "react-day-picker";
|
||||
import { addMinutes, format } from "date-fns";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/src/components/ui/select";
|
||||
import { format } from "date-fns";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
type DateTimeAggregationOption,
|
||||
dateTimeAggregationSettings,
|
||||
dateTimeAggregationOptions,
|
||||
} from "@/src/features/dashboard/lib/timeseries-aggregation";
|
||||
import { useMediaQuery } from "react-responsive";
|
||||
import { type DashboardDateRange } from "@/src/pages/project/[projectId]";
|
||||
import { isValidOption } from "@/src/utils/types";
|
||||
import { setBeginningOfDay, setEndOfDay } from "@/src/utils/dates";
|
||||
|
||||
export const DEFAULT_DATE_RANGE_SELECTION = "Date range" as const;
|
||||
export type AvailableDateRangeSelections =
|
||||
| typeof DEFAULT_DATE_RANGE_SELECTION
|
||||
| DateTimeAggregationOption;
|
||||
import { TimePicker } from "@/src/components/ui/time-picker";
|
||||
import { DashboardDateRangeDropdown } from "@/src/components/date-range-dropdowns";
|
||||
import {
|
||||
DASHBOARD_AGGREGATION_PLACEHOLDER,
|
||||
type DashboardDateRangeOptions,
|
||||
type DashboardDateRange,
|
||||
} from "@/src/utils/date-range-utils";
|
||||
import { combineDateAndTime } from "@/src/components/ui/time-picker-utils";
|
||||
|
||||
export function DatePicker({
|
||||
date,
|
||||
@@ -41,12 +30,14 @@ export function DatePicker({
|
||||
clearable = false,
|
||||
className,
|
||||
disabled,
|
||||
includeTimePicker,
|
||||
}: {
|
||||
date?: Date | undefined;
|
||||
onChange: (date: Date | undefined) => void;
|
||||
clearable?: boolean;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
includeTimePicker?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-row gap-2 align-middle">
|
||||
@@ -62,7 +53,11 @@ export function DatePicker({
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{date ? format(date, "PPP") : <span>Pick a date</span>}
|
||||
{date ? (
|
||||
format(date, includeTimePicker ? "PPP pp" : "PPP")
|
||||
) : (
|
||||
<span>Pick a date</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0">
|
||||
@@ -72,6 +67,9 @@ export function DatePicker({
|
||||
onSelect={(d) => onChange(d)}
|
||||
initialFocus
|
||||
/>
|
||||
{includeTimePicker && (
|
||||
<TimePicker date={date} setDate={(d) => onChange(d)} />
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{date && clearable && (
|
||||
@@ -91,9 +89,9 @@ export function DatePicker({
|
||||
export type DatePickerWithRangeProps = {
|
||||
dateRange?: DashboardDateRange;
|
||||
className?: string;
|
||||
selectedOption: AvailableDateRangeSelections;
|
||||
selectedOption: DashboardDateRangeOptions;
|
||||
setDateRangeAndOption: (
|
||||
option: AvailableDateRangeSelections,
|
||||
option: DashboardDateRangeOptions,
|
||||
date?: DashboardDateRange,
|
||||
) => void;
|
||||
};
|
||||
@@ -112,18 +110,35 @@ export function DatePickerWithRange({
|
||||
setInternalDateRange(dateRange);
|
||||
}, [dateRange]);
|
||||
|
||||
const onDropDownSelection = (value: string) => {
|
||||
if (isValidOption(value)) {
|
||||
const setting = dateTimeAggregationSettings[value];
|
||||
const fromDate = addMinutes(new Date(), -1 * setting.minutes);
|
||||
const setNewDateRange = (
|
||||
internalDateRange: DateRange | undefined,
|
||||
newFromDate: Date | undefined,
|
||||
newToDate: Date | undefined,
|
||||
): DateRange | undefined => {
|
||||
return internalDateRange
|
||||
? {
|
||||
from: newFromDate ?? internalDateRange.from,
|
||||
to: newToDate ?? internalDateRange.to,
|
||||
}
|
||||
: undefined;
|
||||
};
|
||||
|
||||
setDateRangeAndOption(value, {
|
||||
from: fromDate,
|
||||
to: new Date(),
|
||||
});
|
||||
setInternalDateRange({ from: fromDate, to: new Date() });
|
||||
} else {
|
||||
setDateRangeAndOption(DEFAULT_DATE_RANGE_SELECTION, undefined);
|
||||
const updateDashboardDateRange = (
|
||||
newRange: DateRange | undefined,
|
||||
setDateRangeAndOption: (
|
||||
option: DashboardDateRangeOptions,
|
||||
date?: DashboardDateRange,
|
||||
) => void,
|
||||
) => {
|
||||
if (newRange && newRange.from && newRange.to) {
|
||||
const dashboardDateRange: DashboardDateRange = {
|
||||
from: newRange.from,
|
||||
to: newRange.to,
|
||||
};
|
||||
setDateRangeAndOption(
|
||||
DASHBOARD_AGGREGATION_PLACEHOLDER,
|
||||
dashboardDateRange,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -136,13 +151,29 @@ export function DatePickerWithRange({
|
||||
: undefined;
|
||||
|
||||
setInternalDateRange(newRange);
|
||||
if (newRange && newRange.from && newRange.to) {
|
||||
const dashboardDateRange: DashboardDateRange = {
|
||||
from: newRange.from,
|
||||
to: newRange.to,
|
||||
};
|
||||
setDateRangeAndOption(DEFAULT_DATE_RANGE_SELECTION, dashboardDateRange);
|
||||
}
|
||||
updateDashboardDateRange(newRange, setDateRangeAndOption);
|
||||
};
|
||||
|
||||
const onStartTimeSelection = (date: Date | undefined) => {
|
||||
const newDateTime = combineDateAndTime(internalDateRange?.from, date);
|
||||
const newRange = setNewDateRange(
|
||||
internalDateRange,
|
||||
newDateTime,
|
||||
internalDateRange?.to,
|
||||
);
|
||||
setInternalDateRange(newRange);
|
||||
updateDashboardDateRange(newRange, setDateRangeAndOption);
|
||||
};
|
||||
|
||||
const onEndTimeSelection = (date: Date | undefined) => {
|
||||
const newDateTime = combineDateAndTime(internalDateRange?.to, date);
|
||||
const newRange = setNewDateRange(
|
||||
internalDateRange,
|
||||
internalDateRange?.from,
|
||||
newDateTime,
|
||||
);
|
||||
setInternalDateRange(newRange);
|
||||
updateDashboardDateRange(newRange, setDateRangeAndOption);
|
||||
};
|
||||
|
||||
const isSmallScreen = useMediaQuery({ query: "(max-width: 640px)" });
|
||||
@@ -183,28 +214,54 @@ export function DatePickerWithRange({
|
||||
defaultMonth={internalDateRange?.from}
|
||||
selected={internalDateRange}
|
||||
onSelect={onCalendarSelection}
|
||||
numberOfMonths={isSmallScreen ? 1 : 2} // TODO: make this configurable to screen size
|
||||
numberOfMonths={isSmallScreen ? 1 : 2}
|
||||
/>
|
||||
{!isSmallScreen && (
|
||||
<div className="flex flex-row border-t-2 py-1.5">
|
||||
<div className="px-3">
|
||||
<p className="px-1 text-sm font-medium">Start time</p>
|
||||
<TimePicker
|
||||
date={internalDateRange?.from}
|
||||
setDate={onStartTimeSelection}
|
||||
className="border-0 px-0 pt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-3">
|
||||
<p className="px-1 text-sm font-medium">End time</p>
|
||||
<TimePicker
|
||||
date={internalDateRange?.to}
|
||||
setDate={onEndTimeSelection}
|
||||
className="border-0 px-0 pt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isSmallScreen && (
|
||||
<div className="flex flex-col gap-2 border-t-2 py-1.5">
|
||||
<div className="px-3">
|
||||
<p className="px-1 text-sm font-medium">Start</p>
|
||||
<TimePicker
|
||||
date={internalDateRange?.from}
|
||||
setDate={onStartTimeSelection}
|
||||
className="border-0 px-0 pt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-3">
|
||||
<p className="px-1 text-sm font-medium">End</p>
|
||||
<TimePicker
|
||||
date={internalDateRange?.to}
|
||||
setDate={onEndTimeSelection}
|
||||
className="border-0 px-0 pt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Select value={selectedOption} onValueChange={onDropDownSelection}>
|
||||
<SelectTrigger className="w-[120px] hover:bg-accent hover:text-accent-foreground focus:ring-0 focus:ring-offset-0">
|
||||
<SelectValue placeholder="Select" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" defaultValue={60}>
|
||||
<SelectItem
|
||||
key={DEFAULT_DATE_RANGE_SELECTION}
|
||||
value={DEFAULT_DATE_RANGE_SELECTION}
|
||||
>
|
||||
{DEFAULT_DATE_RANGE_SELECTION}
|
||||
</SelectItem>
|
||||
{dateTimeAggregationOptions.toReversed().map((item) => (
|
||||
<SelectItem key={item} value={item}>
|
||||
{item}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<DashboardDateRangeDropdown
|
||||
selectedOption={selectedOption}
|
||||
setDateRangeAndOption={setDateRangeAndOption}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import * as React from "react";
|
||||
import { addMinutes } from "date-fns";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/src/components/ui/select";
|
||||
|
||||
import {
|
||||
DEFAULT_AGGREGATION_SELECTION,
|
||||
tableDateRangeAggregationSettings,
|
||||
dashboardDateRangeAggregationSettings,
|
||||
DASHBOARD_AGGREGATION_PLACEHOLDER,
|
||||
type DashboardDateRangeOptions,
|
||||
type TableDateRangeOptions,
|
||||
DASHBOARD_AGGREGATION_OPTIONS,
|
||||
TABLE_AGGREGATION_OPTIONS,
|
||||
type DashboardDateRange,
|
||||
} from "@/src/utils/date-range-utils";
|
||||
import { Clock } from "lucide-react";
|
||||
|
||||
type BaseDateRangeDropdownProps<T> = {
|
||||
selectedOption: T;
|
||||
options: T[];
|
||||
onSelectionChange: (value: T) => void;
|
||||
};
|
||||
|
||||
const BaseDateRangeDropdown = <T extends string>({
|
||||
selectedOption,
|
||||
options,
|
||||
onSelectionChange,
|
||||
}: BaseDateRangeDropdownProps<T>) => {
|
||||
return (
|
||||
<Select value={selectedOption} onValueChange={onSelectionChange}>
|
||||
<SelectTrigger className="w-[130px] hover:bg-accent hover:text-accent-foreground focus:ring-0 focus:ring-offset-0">
|
||||
<Clock className="h-4 w-4" />
|
||||
<SelectValue placeholder="Select" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" defaultValue={60}>
|
||||
{options.map((item) => (
|
||||
<SelectItem key={item} value={item}>
|
||||
{item}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
type DashboardDateRangeDropdownProps = {
|
||||
selectedOption: DashboardDateRangeOptions;
|
||||
setDateRangeAndOption: (
|
||||
option: DashboardDateRangeOptions,
|
||||
date?: DashboardDateRange,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const DashboardDateRangeDropdown: React.FC<
|
||||
DashboardDateRangeDropdownProps
|
||||
> = ({ selectedOption, setDateRangeAndOption }) => {
|
||||
const onDropDownSelection = (value: DashboardDateRangeOptions) => {
|
||||
if (value === DASHBOARD_AGGREGATION_PLACEHOLDER) {
|
||||
setDateRangeAndOption(DASHBOARD_AGGREGATION_PLACEHOLDER, undefined);
|
||||
return;
|
||||
}
|
||||
const setting =
|
||||
dashboardDateRangeAggregationSettings[
|
||||
value as keyof typeof dashboardDateRangeAggregationSettings
|
||||
];
|
||||
setDateRangeAndOption(value, {
|
||||
from: addMinutes(new Date(), -setting.minutes),
|
||||
to: new Date(),
|
||||
});
|
||||
};
|
||||
|
||||
const options =
|
||||
selectedOption === DASHBOARD_AGGREGATION_PLACEHOLDER
|
||||
? [...DASHBOARD_AGGREGATION_OPTIONS, DASHBOARD_AGGREGATION_PLACEHOLDER]
|
||||
: [...DASHBOARD_AGGREGATION_OPTIONS];
|
||||
return (
|
||||
<BaseDateRangeDropdown
|
||||
selectedOption={selectedOption}
|
||||
options={options}
|
||||
onSelectionChange={onDropDownSelection}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type TableDateRangeDropdownProps = {
|
||||
selectedOption: TableDateRangeOptions;
|
||||
setDateRangeAndOption: (
|
||||
option: TableDateRangeOptions,
|
||||
date?: DashboardDateRange,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const TableDateRangeDropdown: React.FC<TableDateRangeDropdownProps> = ({
|
||||
selectedOption,
|
||||
setDateRangeAndOption,
|
||||
}) => {
|
||||
const onDropDownSelection = (value: TableDateRangeOptions) => {
|
||||
if (value === DEFAULT_AGGREGATION_SELECTION) {
|
||||
setDateRangeAndOption(DEFAULT_AGGREGATION_SELECTION, undefined);
|
||||
return;
|
||||
}
|
||||
const setting =
|
||||
tableDateRangeAggregationSettings[
|
||||
value as keyof typeof tableDateRangeAggregationSettings
|
||||
];
|
||||
setDateRangeAndOption(value, {
|
||||
from: addMinutes(new Date(), -setting),
|
||||
to: new Date(),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseDateRangeDropdown
|
||||
selectedOption={selectedOption}
|
||||
options={[...TABLE_AGGREGATION_OPTIONS, DEFAULT_AGGREGATION_SELECTION]}
|
||||
onSelectionChange={onDropDownSelection}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -4,89 +4,61 @@ import {
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/src/components/ui/hover-card";
|
||||
import {
|
||||
isBooleanDataType,
|
||||
isCategoricalDataType,
|
||||
} from "@/src/features/manual-scoring/lib/helpers";
|
||||
import { type ScoreSimplified } from "@/src/server/api/routers/generations/getAllQuery";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { MessageCircleMore } from "lucide-react";
|
||||
|
||||
export const GroupedScoreBadges = ({
|
||||
import { type APIScore } from "@/src/features/public-api/types/scores";
|
||||
import { type LastUserScore } from "@/src/features/scores/lib/types";
|
||||
|
||||
export const GroupedScoreBadges = <T extends APIScore | LastUserScore>({
|
||||
scores,
|
||||
variant = "badge",
|
||||
}: {
|
||||
scores: ScoreSimplified[];
|
||||
variant?: "badge" | "headings";
|
||||
scores: T[];
|
||||
}) => {
|
||||
const groupedScores = scores.reduce<Record<string, ScoreSimplified[]>>(
|
||||
(acc, score) => {
|
||||
if (!acc[score.name] || !Array.isArray(acc[score.name])) {
|
||||
acc[score.name] = [score];
|
||||
} else {
|
||||
(acc[score.name] as ScoreSimplified[]).push(score);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
const groupedScores = scores.reduce<Record<string, T[]>>((acc, score) => {
|
||||
if (!acc[score.name] || !Array.isArray(acc[score.name])) {
|
||||
acc[score.name] = [score];
|
||||
} else {
|
||||
acc[score.name].push(score);
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const ScoresOfGroup = (props: {
|
||||
scores: ScoreSimplified[];
|
||||
className?: string;
|
||||
}) => (
|
||||
<div className={cn("text-xs", props.className)}>
|
||||
{props.scores.map((s, i) => (
|
||||
<span key={i} className="group/score ml-1 first:ml-0">
|
||||
{isCategoricalDataType(s.dataType) || isBooleanDataType(s.dataType)
|
||||
? s.stringValue
|
||||
: s.value?.toFixed(2)}
|
||||
{s.comment && (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger className="ml-1 inline-block cursor-pointer">
|
||||
<MessageCircleMore size={12} />
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="overflow-hidden whitespace-normal break-normal">
|
||||
<p>{s.comment}</p>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
<span className="group-last/score:hidden">,</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (variant === "headings")
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{Object.entries(groupedScores)
|
||||
.sort(([a], [b]) => (a < b ? -1 : 1))
|
||||
.map(([name, scores]) => (
|
||||
<div key={name}>
|
||||
<div className="text-xs text-muted-foreground">{name}</div>
|
||||
<ScoresOfGroup scores={scores} />
|
||||
return (
|
||||
<>
|
||||
{Object.entries(groupedScores)
|
||||
.sort(([a], [b]) => (a < b ? -1 : 1))
|
||||
.map(([name, scores]) => (
|
||||
<Badge
|
||||
variant="outline"
|
||||
key={name}
|
||||
className="grid grid-cols-[1fr,auto] gap-1 font-normal"
|
||||
>
|
||||
<p className="truncate" title={name}>
|
||||
{name}:
|
||||
</p>
|
||||
<div className="flex items-center gap-3 text-nowrap">
|
||||
{scores.map((s, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="group/score ml-1 rounded-sm first:ml-0"
|
||||
>
|
||||
{s.stringValue ?? s.value?.toFixed(2) ?? ""}
|
||||
{s.comment && (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger className="ml-1 inline-block cursor-pointer">
|
||||
<MessageCircleMore size={12} />
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="overflow-hidden whitespace-normal break-normal">
|
||||
<p>{s.comment}</p>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
<span className="group-last/score:hidden">,</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<>
|
||||
{Object.entries(groupedScores)
|
||||
.sort(([a], [b]) => (a < b ? -1 : 1))
|
||||
.map(([name, scores]) => (
|
||||
<Badge
|
||||
variant="outline"
|
||||
key={name}
|
||||
className="grid grid-cols-[1fr,auto] gap-1 font-normal"
|
||||
>
|
||||
<p className="truncate" title={name}>
|
||||
{name}:
|
||||
</p>
|
||||
<ScoresOfGroup scores={scores} />
|
||||
</Badge>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
</Badge>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user