Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22a391e315 | ||
|
|
46b19279c4 | ||
|
|
716ed04e60 | ||
|
|
87798edd95 | ||
|
|
7058e05ed5 | ||
|
|
fab0ba4f11 | ||
|
|
2094db0101 | ||
|
|
f23bea9272 | ||
|
|
adb67f23e6 | ||
|
|
ed087ae60b | ||
|
|
229320a3db | ||
|
|
38b3e8aaa4 | ||
|
|
2785aa6a32 | ||
|
|
06c0392e79 | ||
|
|
f9a3107149 | ||
|
|
d987c46fed | ||
|
|
8fdd4a7ca7 | ||
|
|
535e6ecb66 | ||
|
|
204691900d | ||
|
|
a1db67580d | ||
|
|
03094953b8 | ||
|
|
ba3e6eec47 | ||
|
|
93de36d25f | ||
|
|
0f66f37b32 | ||
|
|
b4e3634a14 | ||
|
|
29f1072da4 | ||
|
|
5673849f7b | ||
|
|
9cd7e68efc | ||
|
|
eb84df07bf | ||
|
|
b4ef5887de | ||
|
|
aff5e16351 | ||
|
|
00bb0ce5f8 | ||
|
|
eaf87083c6 | ||
|
|
bfe9269a95 | ||
|
|
54e7bc2298 | ||
|
|
a7720b0e8f | ||
|
|
e1ca51ec33 | ||
|
|
eb26a9861d | ||
|
|
b1692e1786 | ||
|
|
6b7709f8e7 | ||
|
|
6a5a233749 | ||
|
|
b262d7ff4a | ||
|
|
2b7aa21fa5 | ||
|
|
a60bf7ce55 | ||
|
|
0d1f948625 | ||
|
|
cf42dd726d | ||
|
|
8aeec63c07 | ||
|
|
0cd3fcfbeb | ||
|
|
a880bdb3b2 | ||
|
|
5c6d3e14f2 | ||
|
|
6ccc57312c | ||
|
|
c4831d1b25 | ||
|
|
cc08408f0a | ||
|
|
6a41aadc98 | ||
|
|
9d6efbae19 | ||
|
|
74be1e87c1 | ||
|
|
dd823da1da | ||
|
|
992f0affbc | ||
|
|
b8c192324c | ||
|
|
1416e760ff | ||
|
|
4266b46dd9 | ||
|
|
1933f8f201 | ||
|
|
1e35eb3a66 | ||
|
|
5478c5e317 | ||
|
|
140452c89e | ||
|
|
7ac92e83da | ||
|
|
8a65bad7d4 | ||
|
|
6c6348bfbe | ||
|
|
9ff7fc942d | ||
|
|
a3f59e7e95 | ||
|
|
445bdcb65c | ||
|
|
8230aa79b3 | ||
|
|
57ca7fb478 | ||
|
|
2db88101e1 | ||
|
|
669167002a | ||
|
|
2f6ade9354 |
@@ -0,0 +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],
|
||||
|
||||
"onCreateCommand": "npm install -g pnpm@8.15.5",
|
||||
"postCreateCommand": "cp .env.dev.example .env && pnpm run dx-f"
|
||||
}
|
||||
@@ -34,10 +34,6 @@ S3_SECRET_ACCESS_KEY=
|
||||
S3_BUCKET_NAME=
|
||||
S3_REGION=
|
||||
|
||||
# Prompt playground
|
||||
OPENAI_API_KEY=""
|
||||
ANTHROPIC_API_KEY=""
|
||||
|
||||
# Set during docker build of application
|
||||
# Used to disable environment verification at build time
|
||||
# DOCKER_BUILD=1
|
||||
|
||||
@@ -13,11 +13,6 @@ NEXTAUTH_URL="http://localhost:3000"
|
||||
LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES="false"
|
||||
SALT="salt"
|
||||
|
||||
|
||||
# Prompt playground
|
||||
OPENAI_API_KEY=""
|
||||
ANTHROPIC_API_KEY=""
|
||||
|
||||
# Redis
|
||||
REDIS_HOST="127.0.0.1"
|
||||
REDIS_PORT=6379
|
||||
|
||||
+6
-5
@@ -43,6 +43,7 @@ LANGFUSE_CSP_ENFORCE_HTTPS="true"
|
||||
# AUTH_GOOGLE_CLIENT_ID=
|
||||
# AUTH_GOOGLE_CLIENT_SECRET=
|
||||
# AUTH_GOOGLE_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_GOOGLE_ALLOWED_DOMAINS=langfuse.com,google.com # optional allowlist of workspace domains that can sign in via Google
|
||||
# AUTH_GITHUB_CLIENT_ID=
|
||||
# AUTH_GITHUB_CLIENT_SECRET=
|
||||
# AUTH_GITHUB_ALLOW_ACCOUNT_LINKING=false
|
||||
@@ -62,7 +63,11 @@ LANGFUSE_CSP_ENFORCE_HTTPS="true"
|
||||
# AUTH_COGNITO_CLIENT_SECRET=
|
||||
# AUTH_COGNITO_ISSUER=
|
||||
# AUTH_COGNITO_ALLOW_ACCOUNT_LINKING=false
|
||||
|
||||
# AUTH_CUSTOM_CLIENT_ID=
|
||||
# AUTH_CUSTOM_CLIENT_SECRET=
|
||||
# AUTH_CUSTOM_ISSUER=
|
||||
# AUTH_CUSTOM_NAME=
|
||||
# AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING=false
|
||||
|
||||
# Transactional email, optional
|
||||
# Defines the email address to use as the from address.
|
||||
@@ -123,10 +128,6 @@ LANGFUSE_CSP_ENFORCE_HTTPS="true"
|
||||
# NEXT_PUBLIC_TURNSTILE_SITE_KEY=
|
||||
# TURNSTILE_SECRET_KEY=
|
||||
|
||||
# Prompt playground
|
||||
# OPENAI_API_KEY=""
|
||||
# ANTHROPIC_API_KEY=""
|
||||
|
||||
# Betterstack
|
||||
# LANGFUSE_TEAM_BETTERSTACK_TOKEN=
|
||||
|
||||
|
||||
@@ -40,25 +40,34 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Build Web Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./web/Dockerfile
|
||||
push: false
|
||||
- name: Build and run both images from compose
|
||||
run: |
|
||||
docker compose -f docker-compose.build.yml up -d
|
||||
sleep 5 # Wait for PostgreSQL to accept connections
|
||||
|
||||
- name: Build Worker Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./worker/Dockerfile
|
||||
push: false
|
||||
- name: Check server health
|
||||
run: |
|
||||
timeout 10 bash -c 'until curl -f http://localhost:3000/api/public/health; do sleep 2; done'
|
||||
|
||||
- name: Check worker health
|
||||
run: |
|
||||
timeout 10 bash -c 'until curl -f http://localhost:3030/api/health; do sleep 2; done'
|
||||
|
||||
- name: Ensure no unhealthy status
|
||||
run: |
|
||||
if docker-compose ps | grep "(unhealthy)"; then
|
||||
echo "One or more services are unhealthy"
|
||||
exit 1
|
||||
else
|
||||
echo "All services are healthy"
|
||||
fi
|
||||
|
||||
tests-web:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20]
|
||||
postgres-version: [12, 15]
|
||||
steps:
|
||||
- name: Set Swap Space
|
||||
uses: pierotofy/set-swap-space@master
|
||||
@@ -89,6 +98,9 @@ jobs:
|
||||
run: |
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
sleep 5 # Wait for PostgreSQL to accept connections
|
||||
docker compose ps
|
||||
env:
|
||||
POSTGRES_VERSION: ${{ matrix.postgres-version }}
|
||||
|
||||
- name: Seed DB
|
||||
run: |
|
||||
@@ -109,6 +121,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20]
|
||||
postgres-version: [12, 15]
|
||||
steps:
|
||||
- name: Set Swap Space
|
||||
uses: pierotofy/set-swap-space@master
|
||||
@@ -139,6 +152,7 @@ jobs:
|
||||
run: |
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
sleep 5 # Wait for PostgreSQL to accept connections
|
||||
docker compose ps
|
||||
|
||||
- name: Seed DB
|
||||
run: |
|
||||
@@ -176,6 +190,7 @@ jobs:
|
||||
- name: Run + migrate
|
||||
run: |
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
docker compose ps
|
||||
sleep 5 # Wait for PostgreSQL to accept connections
|
||||
|
||||
- name: Seed DB
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
name: Snyk Container
|
||||
on: push
|
||||
jobs:
|
||||
snyk:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Build a Docker image
|
||||
run: docker compose -f docker-compose.build.yml up -d
|
||||
|
||||
- name: Run Snyk to check Docker image for vulnerabilities (langfuse-server)
|
||||
continue-on-error: true
|
||||
uses: snyk/actions/docker@master
|
||||
env:
|
||||
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
|
||||
with:
|
||||
image: langfuse-server
|
||||
args: --file=web/Dockerfile
|
||||
|
||||
- name: Upload result to GitHub Code Scanning
|
||||
uses: github/codeql-action/upload-sarif@v2
|
||||
with:
|
||||
sarif_file: snyk.sarif
|
||||
category: web
|
||||
|
||||
- name: Run Snyk to check Docker image for vulnerabilities (langfuse-worker)
|
||||
continue-on-error: true
|
||||
uses: snyk/actions/docker@master
|
||||
env:
|
||||
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
|
||||
with:
|
||||
image: langfuse-worker
|
||||
args: --file=worker/Dockerfile
|
||||
|
||||
- name: Upload result to GitHub Code Scanning
|
||||
uses: github/codeql-action/upload-sarif@v2
|
||||
with:
|
||||
sarif_file: snyk.sarif
|
||||
category: worker
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Get the current branch
|
||||
current_branch=$(git rev-parse --abbrev-ref HEAD)
|
||||
|
||||
# Define the protected branch
|
||||
protected_branch="main"
|
||||
|
||||
# Check if the current branch is the protected branch
|
||||
if [ "$current_branch" = "$protected_branch" ]; then
|
||||
echo "🚨 You are about to commit to the $protected_branch branch. Are you sure? (y/n)"
|
||||
read -r answer < /dev/tty
|
||||
if [ "$answer" != "${answer#[Yy]}" ]; then
|
||||
exit 0 # Commit will proceed
|
||||
else
|
||||
echo "Commit to $protected_branch branch has been canceled."
|
||||
exit 1 # Commit will be blocked
|
||||
fi
|
||||
fi
|
||||
|
||||
# If not the protected branch, proceed with the commit
|
||||
exit 0
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Get the current branch
|
||||
current_branch=$(git rev-parse --abbrev-ref HEAD)
|
||||
|
||||
# Define the protected branch
|
||||
protected_branch="main"
|
||||
|
||||
# Check if the current branch is the protected branch
|
||||
if [ "$current_branch" = "$protected_branch" ]; then
|
||||
echo "🚨 You are about to push to the $protected_branch branch. Are you sure? (y/n)"
|
||||
read -r answer < /dev/tty
|
||||
if [ "$answer" != "${answer#[Yy]}" ]; then
|
||||
exit 0 # Push will proceed
|
||||
else
|
||||
echo "Push to $protected_branch branch has been canceled."
|
||||
exit 1 # Push will be blocked
|
||||
fi
|
||||
fi
|
||||
|
||||
# If not the protected branch, proceed with the push
|
||||
exit 0
|
||||
+15
-1
@@ -151,6 +151,8 @@ Requirements
|
||||
- Node.js 20 as specified in the [.nvmrc](.nvmrc)
|
||||
- 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".
|
||||
|
||||
**Steps**
|
||||
|
||||
1. Fork the repository and clone it locally
|
||||
@@ -191,6 +193,16 @@ Requirements
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
7. Open the web app in the browser:
|
||||
|
||||
http://localhost:3000
|
||||
|
||||
8. Log in as a test user (after you ran `db:seed` command):
|
||||
|
||||
Username: demo@langfuse.com
|
||||
|
||||
Password: password
|
||||
|
||||
## Monorepo quickstart
|
||||
|
||||
- Available packages and their dependencies
|
||||
@@ -358,7 +370,9 @@ The background color of the following component will be `hsl(var(--primary))` an
|
||||
|
||||
By following these guidelines, you can ensure that any contributions to our theme are consistent, maintainable, and aligned with our design system.
|
||||
|
||||
## Using secrets stored in 1Password
|
||||
## Maintainers
|
||||
|
||||
### Using secrets stored in 1Password
|
||||
|
||||
When applying changes to non-local environments, you may need to use secrets stored in 1Password. We use the 1Password CLI for this purpose.
|
||||
|
||||
|
||||
@@ -20,6 +20,11 @@ services:
|
||||
- LANGFUSE_WORKER_HOST=${LANGFUSE_WORKER_HOST:-worker}
|
||||
- LANGFUSE_WORKER_PASSWORD=${LANGFUSE_WORKER_PASSWORD:-mybasicauthsecret}
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/api/public/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
worker:
|
||||
build:
|
||||
@@ -41,6 +46,11 @@ services:
|
||||
- REDIS_AUTH=${REDIS_AUTH:-myredissecret}
|
||||
- LANGFUSE_WORKER_PASSWORD=${LANGFUSE_WORKER_PASSWORD:-mybasicauthsecret}
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3030/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
redis:
|
||||
image: redis:7.2.4
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
version: "3.5"
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:7.2.4
|
||||
@@ -10,7 +8,7 @@ services:
|
||||
- 6379:6379
|
||||
|
||||
db:
|
||||
image: postgres
|
||||
image: postgres:${POSTGRES_VERSION:-latest}
|
||||
restart: always
|
||||
command: ["postgres", "-c", "log_statement=all"]
|
||||
environment:
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ version: "3.5"
|
||||
|
||||
services:
|
||||
langfuse-server:
|
||||
image: ghcr.io/langfuse/langfuse:latest
|
||||
image: langfuse/langfuse:2
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@
|
||||
"@repo/eslint-config": "*",
|
||||
"@repo/typescript-config": "*",
|
||||
"@types/node": "^20.11.29",
|
||||
"@typescript-eslint/parser": "^7.7.0",
|
||||
"@typescript-eslint/parser": "^7.12.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-standard": "^17.1.0",
|
||||
|
||||
@@ -11,6 +11,7 @@ import { encrypt, decrypt } from "@langfuse/shared/encryption";
|
||||
import { SsoProviderSchema } from "./types";
|
||||
import { type NextApiRequest, type NextApiResponse } from "next";
|
||||
import { env } from "../env";
|
||||
import { CustomSSOProvider } from "@langfuse/shared/src/server/auth";
|
||||
|
||||
// Local cache for SSO configurations
|
||||
let cachedSsoConfigs: {
|
||||
@@ -173,6 +174,12 @@ const dbToNextAuthProvider = (provider: SsoProviderSchema): Provider | null => {
|
||||
...provider.authConfig,
|
||||
clientSecret: decrypt(provider.authConfig.clientSecret),
|
||||
});
|
||||
else if (provider.authProvider === "custom")
|
||||
return CustomSSOProvider({
|
||||
id: getAuthProviderIdForSsoConfig(provider), // use the domain as the provider id as we use domain-specific credentials
|
||||
...provider.authConfig,
|
||||
clientSecret: decrypt(provider.authConfig.clientSecret),
|
||||
});
|
||||
else {
|
||||
// Type check to ensure we handle all providers
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
|
||||
@@ -76,12 +76,26 @@ export const CognitoProviderSchema = base.extend({
|
||||
.nullish(),
|
||||
});
|
||||
|
||||
export const CustomProviderSchema = base.extend({
|
||||
authProvider: z.literal("custom"),
|
||||
authConfig: z
|
||||
.object({
|
||||
name: z.string(),
|
||||
clientId: z.string(),
|
||||
clientSecret: z.string(),
|
||||
issuer: z.string(),
|
||||
allowDangerousEmailAccountLinking: z.boolean().optional().default(false),
|
||||
})
|
||||
.nullish(),
|
||||
});
|
||||
|
||||
export type GoogleProviderSchema = z.infer<typeof GoogleProviderSchema>;
|
||||
export type GithubProviderSchema = z.infer<typeof GithubProviderSchema>;
|
||||
export type Auth0ProviderSchema = z.infer<typeof Auth0ProviderSchema>;
|
||||
export type OktaProviderSchema = z.infer<typeof OktaProviderSchema>;
|
||||
export type AzureAdProviderSchema = z.infer<typeof AzureAdProviderSchema>;
|
||||
export type CognitoProviderSchema = z.infer<typeof CognitoProviderSchema>;
|
||||
export type CustomProviderSchema = z.infer<typeof CustomProviderSchema>;
|
||||
|
||||
export const SsoProviderSchema = z.discriminatedUnion("authProvider", [
|
||||
GoogleProviderSchema,
|
||||
@@ -90,6 +104,7 @@ export const SsoProviderSchema = z.discriminatedUnion("authProvider", [
|
||||
OktaProviderSchema,
|
||||
AzureAdProviderSchema,
|
||||
CognitoProviderSchema,
|
||||
CustomProviderSchema,
|
||||
]);
|
||||
|
||||
export type SsoProviderSchema = z.infer<typeof SsoProviderSchema>;
|
||||
|
||||
@@ -7,28 +7,52 @@ service:
|
||||
base-path: /api/public
|
||||
endpoints:
|
||||
batch:
|
||||
docs: Batched ingestion for Langfuse Tracing
|
||||
docs: Batched ingestion for Langfuse Tracing. If you want to use tracing via the API, such as to build your own Langfuse client implementation, this is the only API route you need to implement.
|
||||
method: POST
|
||||
path: /ingestion
|
||||
request:
|
||||
name: IngestionRequest
|
||||
body:
|
||||
properties:
|
||||
batch: list<IngestionEvent>
|
||||
response: IngestionResponse # will be reportet as 200 response, but endpoint returns 207
|
||||
batch:
|
||||
type: list<IngestionEvent>
|
||||
docs: "Batch of tracing events to be ingested. Discriminated by attribute `type`."
|
||||
metadata:
|
||||
type: optional<unknown>
|
||||
docs: Optional. Metadata field used by the Langfuse SDKs for debugging.
|
||||
response:
|
||||
type: IngestionResponse
|
||||
status-code: 207
|
||||
|
||||
types:
|
||||
IngestionEvent:
|
||||
discriminant: "type"
|
||||
union:
|
||||
trace-create: TraceEvent
|
||||
score-create: ScoreEvent
|
||||
event-create: CreateEventEvent
|
||||
generation-create: CreateGenerationEvent
|
||||
generation-update: UpdateGenerationEvent
|
||||
span-create: CreateSpanEvent
|
||||
span-update: UpdateSpanEvent
|
||||
sdk-log: SDKLogEvent
|
||||
trace-create:
|
||||
type: TraceEvent
|
||||
docs: Creates a new trace. Upserts on id for updates if trace with id exists.
|
||||
score-create:
|
||||
type: ScoreEvent
|
||||
docs: Creates a new score. Upserts on id for updates if score with id exists.
|
||||
span-create:
|
||||
type: CreateSpanEvent
|
||||
docs: Creates a new span.
|
||||
span-update:
|
||||
type: UpdateSpanEvent
|
||||
docs: Updates span based on id.
|
||||
generation-create:
|
||||
type: CreateGenerationEvent
|
||||
docs: Creates a new generation.
|
||||
generation-update:
|
||||
type: UpdateGenerationEvent
|
||||
docs: Updates a generation based on id.
|
||||
event-create:
|
||||
type: CreateEventEvent
|
||||
docs: Creates an event.
|
||||
|
||||
sdk-log:
|
||||
type: SDKLogEvent
|
||||
docs: Langfuse SDKs only, used for debugging purposes.
|
||||
|
||||
# both are legacy
|
||||
observation-create:
|
||||
@@ -162,9 +186,15 @@ types:
|
||||
|
||||
BaseEvent:
|
||||
properties:
|
||||
id: string
|
||||
timestamp: string
|
||||
metadata: unknown
|
||||
id:
|
||||
type: string
|
||||
docs: UUID v4 that identifies the event
|
||||
timestamp:
|
||||
type: string
|
||||
docs: "Datetime (ISO 8601) of event creation in client. Should be as close to actual event creation in client as possible, this timestamp will be used for ordering of events in future release. Resolution: milliseconds (required), microseconds (optimal)."
|
||||
metadata:
|
||||
type: optional<unknown>
|
||||
docs: Optional. Metadata field used by the Langfuse SDKs for debugging.
|
||||
|
||||
TraceEvent:
|
||||
extends: BaseEvent
|
||||
|
||||
@@ -31,7 +31,7 @@ service:
|
||||
type: optional<datetime>
|
||||
docs: Retrieve only scores newer than this datetime (ISO 8601).
|
||||
source:
|
||||
type: commons.ScoreSource
|
||||
type: optional<commons.ScoreSource>
|
||||
docs: Retrieve only scores from a specific source.
|
||||
operator:
|
||||
type: optional<string>
|
||||
@@ -39,6 +39,9 @@ service:
|
||||
value:
|
||||
type: optional<double>
|
||||
docs: Retrieve only scores with <operator> value.
|
||||
scoreIds:
|
||||
type: optional<string>
|
||||
docs: Comma-separated list of score IDs to limit the results to.
|
||||
response: Scores
|
||||
get-by-id:
|
||||
docs: Get a score
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"organization": "finto",
|
||||
"version": "0.21.0"
|
||||
"version": "0.30.3"
|
||||
}
|
||||
+15
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse",
|
||||
"version": "2.48.0",
|
||||
"version": "2.54.0",
|
||||
"author": "engineering@langfuse.com",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
@@ -17,26 +17,36 @@
|
||||
"db:seed": "turbo run db:seed",
|
||||
"db:seed:examples": "turbo run db:seed:examples",
|
||||
"nuke": "bash ./scripts/nuke.sh",
|
||||
"dx": "pnpm i && pnpm --filter=shared run db:reset && 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 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",
|
||||
"build": "turbo run build",
|
||||
"start": "turbo run start",
|
||||
"dev": "turbo run dev",
|
||||
"lint": "turbo run lint",
|
||||
"test": "turbo run test",
|
||||
"models:migrate": "turbo run models:migrate",
|
||||
"release": "dotenv -e ../.env -- release-it"
|
||||
"release": "dotenv -e ../.env -- release-it",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@release-it/bumper": "^6.0.1",
|
||||
"braces": "3.0.3",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"husky": "^9.0.11",
|
||||
"prettier": "^3.2.5",
|
||||
"release-it": "^17.2.1",
|
||||
"release-it": "^17.3.0",
|
||||
"turbo": "^1.13.3"
|
||||
},
|
||||
"release-it": {
|
||||
"git": {
|
||||
"commitMessage": "chore: release v${version}",
|
||||
"tagName": "v${version}"
|
||||
"tagName": "v${version}",
|
||||
"commitArgs": [
|
||||
"--no-verify"
|
||||
],
|
||||
"pushArgs": [
|
||||
"--no-verify"
|
||||
]
|
||||
},
|
||||
"plugins": {
|
||||
"@release-it/bumper": {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
],
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^7.1.0",
|
||||
"@typescript-eslint/parser": "^7.7.0",
|
||||
"@typescript-eslint/parser": "^7.12.0",
|
||||
"@vercel/style-guide": "^6.0.0",
|
||||
"eslint-config-next": "^14.2.3",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
|
||||
@@ -52,6 +52,8 @@
|
||||
"bcryptjs": "^2.4.3",
|
||||
"kysely": "^0.27.3",
|
||||
"langchain": "^0.1.36",
|
||||
"lodash": "^4.17.21",
|
||||
"next-auth": "^4.24.7",
|
||||
"prisma-extension-kysely": "^2.1.0",
|
||||
"zod": "^3.22.4",
|
||||
"zod-to-json-schema": "^3.22.5"
|
||||
@@ -59,17 +61,17 @@
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "*",
|
||||
"@repo/typescript-config": "*",
|
||||
"@types/lodash": "^4.17.4",
|
||||
"@types/lodash": "^4.17.5",
|
||||
"@types/node": "^20.11.29",
|
||||
"@types/pg": "^8.11.6",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@typescript-eslint/parser": "^7.7.0",
|
||||
"@typescript-eslint/parser": "^7.12.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-standard": "^17.1.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"kysely-codegen": "^0.11.0",
|
||||
"nodemon": "^3.0.3",
|
||||
"nodemon": "^3.1.3",
|
||||
"prettier": "^3.2.5",
|
||||
"prisma": "^5.13.0",
|
||||
"prisma-erd-generator": "^1.11.2",
|
||||
|
||||
@@ -36,17 +36,6 @@ export const ScoreDataType = {
|
||||
BOOLEAN: "BOOLEAN"
|
||||
} as const;
|
||||
export type ScoreDataType = (typeof ScoreDataType)[keyof typeof ScoreDataType];
|
||||
export const PricingUnit = {
|
||||
PER_1000_TOKENS: "PER_1000_TOKENS",
|
||||
PER_1000_CHARS: "PER_1000_CHARS"
|
||||
} as const;
|
||||
export type PricingUnit = (typeof PricingUnit)[keyof typeof PricingUnit];
|
||||
export const TokenType = {
|
||||
PROMPT: "PROMPT",
|
||||
COMPLETION: "COMPLETION",
|
||||
TOTAL: "TOTAL"
|
||||
} as const;
|
||||
export type TokenType = (typeof TokenType)[keyof typeof TokenType];
|
||||
export const DatasetStatus = {
|
||||
ACTIVE: "ACTIVE",
|
||||
ARCHIVED: "ARCHIVED"
|
||||
@@ -163,6 +152,7 @@ export type EvalTemplate = {
|
||||
version: number;
|
||||
prompt: string;
|
||||
model: string;
|
||||
provider: string;
|
||||
model_params: unknown;
|
||||
vars: Generated<string[]>;
|
||||
output_schema: unknown;
|
||||
@@ -210,8 +200,12 @@ export type LlmApiKeys = {
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
provider: string;
|
||||
adapter: string;
|
||||
display_secret_key: string;
|
||||
secret_key: string;
|
||||
base_url: string | null;
|
||||
custom_models: Generated<string[]>;
|
||||
with_default_models: Generated<boolean>;
|
||||
project_id: string;
|
||||
};
|
||||
export type MembershipInvitation = {
|
||||
@@ -310,14 +304,6 @@ export type PosthogIntegration = {
|
||||
enabled: boolean;
|
||||
created_at: Generated<Timestamp>;
|
||||
};
|
||||
export type Pricing = {
|
||||
id: string;
|
||||
model_name: string;
|
||||
pricing_unit: Generated<PricingUnit>;
|
||||
price: string;
|
||||
currency: Generated<string>;
|
||||
token_type: TokenType;
|
||||
};
|
||||
export type Project = {
|
||||
id: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
@@ -471,7 +457,6 @@ export type DB = {
|
||||
observations: Observation;
|
||||
observations_view: ObservationView;
|
||||
posthog_integrations: PosthogIntegration;
|
||||
pricings: Pricing;
|
||||
project_memberships: ProjectMembership;
|
||||
projects: Project;
|
||||
prompts: Prompt;
|
||||
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
-- Google Vertex uses @ to separate model name and version
|
||||
|
||||
UPDATE "public"."models" SET "match_pattern" = '(?i)^(claude-3-haiku(-|@)?20240307)$' WHERE "id" = 'cltr0w45b000008k1407o9qv1';
|
||||
|
||||
UPDATE "public"."models" SET "match_pattern" = '(?i)^(claude-3-opus(-|@)?20240229)$' WHERE "id" = 'cltgy0iuw000008le3vod1hhy';
|
||||
|
||||
UPDATE "public"."models" SET "match_pattern" = '(?i)^(claude-3-sonnet(-|@)?20240229)$' WHERE "id" = 'cltgy0pp6000108le56se7bl3';
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the `pricings` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- DropTable
|
||||
DROP TABLE "pricings";
|
||||
|
||||
-- DropEnum
|
||||
DROP TYPE "PricingUnit";
|
||||
|
||||
-- DropEnum
|
||||
DROP TYPE "TokenType";
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "dataset_run_items" DROP CONSTRAINT "dataset_run_items_trace_id_fkey";
|
||||
ALTER TABLE "dataset_run_items" DROP CONSTRAINT "dataset_run_items_observation_id_fkey";
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
-- does not include pricing yet, will be added as soon as it is calculated at ingestion time
|
||||
|
||||
-- remove model if added by faulty migration, context: https://github.com/langfuse/langfuse/issues/2266
|
||||
DELETE FROM models WHERE id in ('clx30djsn0000w9mzebiv41we', 'clx30hkrx0000w9mz7lqi0ial');
|
||||
|
||||
INSERT INTO "models" ("id", "model_name", "match_pattern", "unit") VALUES ('clx30djsn0000w9mzebiv41we', 'gemini-1.5-flash', '(?i)^(gemini-1.5-flash)(@[a-zA-Z0-9]+)?$', 'CHARACTERS');
|
||||
|
||||
INSERT INTO "models" ("id", "model_name", "match_pattern", "unit") VALUES ('clx30hkrx0000w9mz7lqi0ial', 'gemini-1.5-pro', '(?i)^(gemini-1.5-pro)(@[a-zA-Z0-9]+)?$', 'CHARACTERS');
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
-- Google Vertex uses @ to separate model name and version
|
||||
|
||||
UPDATE "models" SET "match_pattern" = '(?i)^(claude-3-haiku(-|@)?20240307)$' WHERE "id" = 'cltr0w45b000008k1407o9qv1';
|
||||
|
||||
UPDATE "models" SET "match_pattern" = '(?i)^(claude-3-opus(-|@)?20240229)$' WHERE "id" = 'cltgy0iuw000008le3vod1hhy';
|
||||
|
||||
UPDATE "models" SET "match_pattern" = '(?i)^(claude-3-sonnet(-|@)?20240229)$' WHERE "id" = 'cltgy0pp6000108le56se7bl3';
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE "llm_api_keys"
|
||||
ADD COLUMN "base_url" TEXT,
|
||||
ADD COLUMN "adapter" TEXT,
|
||||
ADD COLUMN "custom_models" TEXT[] NOT NULL DEFAULT '{}'::TEXT[],
|
||||
ADD COLUMN "with_default_models" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
UPDATE "llm_api_keys"
|
||||
SET "adapter" = "provider";
|
||||
|
||||
ALTER TABLE "llm_api_keys"
|
||||
ALTER COLUMN "adapter" SET NOT NULL;
|
||||
|
||||
ALTER TABLE "eval_templates"
|
||||
ADD COLUMN "provider" TEXT;
|
||||
|
||||
UPDATE "eval_templates"
|
||||
SET "provider" = 'openai';
|
||||
|
||||
ALTER TABLE "eval_templates"
|
||||
ALTER COLUMN "provider" SET NOT NULL;
|
||||
|
||||
COMMIT;
|
||||
+3
-2
@@ -10,7 +10,7 @@ INSERT INTO score_configs (id,
|
||||
max_value,
|
||||
description)
|
||||
SELECT
|
||||
gen_random_uuid () AS id,
|
||||
md5(random()::text || clock_timestamp()::text || s.project_id::text)::uuid AS id,
|
||||
s.project_id,
|
||||
'manual-score',
|
||||
'NUMERIC',
|
||||
@@ -24,6 +24,7 @@ FROM ( SELECT DISTINCT
|
||||
scores
|
||||
WHERE
|
||||
name = 'manual-score'
|
||||
AND config_id IS NULL
|
||||
AND source = 'ANNOTATION') s
|
||||
WHERE
|
||||
NOT EXISTS (
|
||||
@@ -47,4 +48,4 @@ FROM
|
||||
WHERE
|
||||
scores.project_id = pc.project_id
|
||||
AND scores.name = 'manual-score';
|
||||
COMMIT;
|
||||
COMMIT;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX CONCURRENTLY "observations_project_id_prompt_id_idx" ON "observations"("project_id", "prompt_id");
|
||||
@@ -144,9 +144,13 @@ model LlmApiKeys {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
|
||||
provider String
|
||||
displaySecretKey String @map("display_secret_key")
|
||||
secretKey String @map("secret_key")
|
||||
provider String
|
||||
adapter String // This controls the interface that is used to connect with the LLM, e.g. 'openai' or 'anthropic'
|
||||
displaySecretKey String @map("display_secret_key")
|
||||
secretKey String @map("secret_key")
|
||||
baseURL String? @map("base_url")
|
||||
customModels String[] @default([]) @map("custom_models")
|
||||
withDefaultModels Boolean @default(true) @map("with_default_models")
|
||||
|
||||
projectId String @map("project_id")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
@@ -233,9 +237,8 @@ model Trace {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
|
||||
DatasetRunItems DatasetRunItems[]
|
||||
DatasetItem DatasetItem[]
|
||||
JobExecution JobExecution[]
|
||||
DatasetItem DatasetItem[]
|
||||
JobExecution JobExecution[]
|
||||
|
||||
@@index([projectId])
|
||||
@@index([sessionId])
|
||||
@@ -298,21 +301,20 @@ model Observation {
|
||||
|
||||
// GENERATION ONLY
|
||||
model String?
|
||||
internalModel String? @map("internal_model")
|
||||
internalModel String? @map("internal_model")
|
||||
modelParameters Json?
|
||||
input Json?
|
||||
output Json?
|
||||
promptTokens Int @default(0) @map("prompt_tokens")
|
||||
completionTokens Int @default(0) @map("completion_tokens")
|
||||
totalTokens Int @default(0) @map("total_tokens")
|
||||
promptTokens Int @default(0) @map("prompt_tokens")
|
||||
completionTokens Int @default(0) @map("completion_tokens")
|
||||
totalTokens Int @default(0) @map("total_tokens")
|
||||
unit String?
|
||||
inputCost Decimal? @map("input_cost")
|
||||
outputCost Decimal? @map("output_cost")
|
||||
totalCost Decimal? @map("total_cost")
|
||||
completionStartTime DateTime? @map("completion_start_time")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
inputCost Decimal? @map("input_cost")
|
||||
outputCost Decimal? @map("output_cost")
|
||||
totalCost Decimal? @map("total_cost")
|
||||
completionStartTime DateTime? @map("completion_start_time")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
derivedDatasetItems DatasetItem[]
|
||||
datasetRunItems DatasetRunItems[]
|
||||
|
||||
promptId String? @map("prompt_id")
|
||||
prompt Prompt? @relation(fields: [promptId], onDelete: SetNull, references: [id])
|
||||
@@ -331,6 +333,7 @@ model Observation {
|
||||
@@index(parentObservationId)
|
||||
@@index(model)
|
||||
@@index(internalModel)
|
||||
@@index([projectId, promptId])
|
||||
@@index(promptId)
|
||||
@@index([projectId, startTime, type])
|
||||
@@map("observations")
|
||||
@@ -467,29 +470,6 @@ enum ScoreDataType {
|
||||
BOOLEAN
|
||||
}
|
||||
|
||||
enum PricingUnit {
|
||||
PER_1000_TOKENS
|
||||
PER_1000_CHARS
|
||||
}
|
||||
|
||||
enum TokenType {
|
||||
PROMPT
|
||||
COMPLETION
|
||||
TOTAL
|
||||
}
|
||||
|
||||
model Pricing {
|
||||
id String @id @default(cuid())
|
||||
modelName String @map("model_name")
|
||||
pricingUnit PricingUnit @default(PER_1000_TOKENS) @map("pricing_unit")
|
||||
price Decimal
|
||||
currency String @default("USD")
|
||||
tokenType TokenType @map("token_type")
|
||||
|
||||
@@index(modelName)
|
||||
@@map("pricings")
|
||||
}
|
||||
|
||||
model CronJobs {
|
||||
name String @id
|
||||
lastRun DateTime? @map("last_run")
|
||||
@@ -566,17 +546,15 @@ model DatasetRuns {
|
||||
}
|
||||
|
||||
model DatasetRunItems {
|
||||
id String @id @default(cuid())
|
||||
datasetRunId String @map("dataset_run_id")
|
||||
datasetRun DatasetRuns @relation(fields: [datasetRunId], references: [id], onDelete: Cascade)
|
||||
datasetItemId String @map("dataset_item_id")
|
||||
datasetItem DatasetItem @relation(fields: [datasetItemId], references: [id], onDelete: Cascade)
|
||||
traceId String @map("trace_id")
|
||||
trace Trace @relation(fields: [traceId], references: [id], onDelete: Cascade)
|
||||
observationId String? @map("observation_id")
|
||||
observation Observation? @relation(fields: [observationId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
id String @id @default(cuid())
|
||||
datasetRunId String @map("dataset_run_id")
|
||||
datasetRun DatasetRuns @relation(fields: [datasetRunId], references: [id], onDelete: Cascade)
|
||||
datasetItemId String @map("dataset_item_id")
|
||||
datasetItem DatasetItem @relation(fields: [datasetItemId], references: [id], 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")
|
||||
|
||||
@@index([datasetRunId], type: Hash)
|
||||
@@index([datasetItemId], type: Hash)
|
||||
@@ -691,6 +669,7 @@ model EvalTemplate {
|
||||
version Int
|
||||
prompt String
|
||||
model String
|
||||
provider String
|
||||
modelParams Json @map("model_params")
|
||||
vars String[] @default([])
|
||||
outputSchema Json @map("output_schema")
|
||||
|
||||
@@ -184,6 +184,7 @@ async function main() {
|
||||
secretKey: encrypt(OPENAI_API_KEY),
|
||||
displaySecretKey: getDisplaySecretKey(OPENAI_API_KEY),
|
||||
provider: "openai",
|
||||
adapter: "openai",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
@@ -209,6 +210,7 @@ async function main() {
|
||||
"Please evaluate the toxicity of the following text {{input}} {{output}}",
|
||||
model: "gpt-3.5-turbo",
|
||||
vars: ["input", "output"],
|
||||
provider: "openai",
|
||||
outputSchema: {
|
||||
score: "provide a score between 0 and 1",
|
||||
reasoning: "one sentence reasoning for the score",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = '_prisma_migrations') THEN
|
||||
DELETE FROM _prisma_migrations
|
||||
WHERE migration_name IN ('20240606090858_pricings_add_latest_gemini_models', '20240530212419_model_price_anthropic_via_google_vertex', '20240604133340_backfill_manual_scores');
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -1,5 +1,5 @@
|
||||
import z from "zod";
|
||||
import { ModelProvider, UIModelParams } from "../..";
|
||||
import { LLMAdapter } from "../..";
|
||||
|
||||
export const langfuseObjects = [
|
||||
"trace",
|
||||
@@ -77,38 +77,15 @@ export const availableEvalVariables = [
|
||||
},
|
||||
];
|
||||
|
||||
export const evalLLMModels: UIModelParams[] = [
|
||||
{
|
||||
provider: { value: ModelProvider.OpenAI, enabled: true },
|
||||
model: { value: "gpt-3.5-turbo", enabled: true },
|
||||
temperature: { value: 1, enabled: true },
|
||||
maxTemperature: { value: 2, enabled: true },
|
||||
max_tokens: { value: 256, enabled: true },
|
||||
top_p: { value: 1, enabled: true },
|
||||
},
|
||||
{
|
||||
provider: { value: ModelProvider.OpenAI, enabled: true },
|
||||
model: { value: "gpt-4-turbo-preview", enabled: true },
|
||||
temperature: { value: 1, enabled: true },
|
||||
maxTemperature: { value: 2, enabled: true },
|
||||
max_tokens: { value: 256, enabled: true },
|
||||
top_p: { value: 1, enabled: true },
|
||||
},
|
||||
{
|
||||
provider: { value: ModelProvider.OpenAI, enabled: true },
|
||||
model: { value: "gpt-4o", enabled: true },
|
||||
temperature: { value: 1, enabled: true },
|
||||
maxTemperature: { value: 2, enabled: true },
|
||||
max_tokens: { value: 256, enabled: true },
|
||||
top_p: { value: 1, enabled: true },
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const EvalModelNames = z.enum([
|
||||
"gpt-3.5-turbo",
|
||||
"gpt-4-turbo-preview",
|
||||
"gpt-4o",
|
||||
]);
|
||||
export const evalModelsByAdapter: Record<LLMAdapter, string[]> = {
|
||||
[LLMAdapter.OpenAI]: [
|
||||
"gpt-4o",
|
||||
"gpt-4-turbo-preview",
|
||||
"gpt-3.5-turbo",
|
||||
] as const,
|
||||
[LLMAdapter.Anthropic]: [] as const,
|
||||
[LLMAdapter.Azure]: [] as const,
|
||||
};
|
||||
|
||||
export const OutputSchema = z.object({
|
||||
reasoning: z.string(),
|
||||
|
||||
+8
-8
@@ -1,9 +1,9 @@
|
||||
import lodash from "lodash";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ModelUsageUnit } from "@langfuse/shared";
|
||||
import { NonEmptyString, jsonSchema } from "@/src/utils/zod";
|
||||
import { ObservationLevel } from "@langfuse/shared";
|
||||
import { NonEmptyString, jsonSchema } from "../../utils/zod";
|
||||
import { ModelUsageUnit } from "../../constants";
|
||||
import { ObservationLevel } from "@prisma/client";
|
||||
|
||||
export const Usage = z.object({
|
||||
input: z.number().int().nullish(),
|
||||
@@ -109,7 +109,7 @@ export const CreateGenerationBody = CreateSpanBody.extend({
|
||||
z.string(),
|
||||
z
|
||||
.union([z.string(), z.number(), z.boolean(), z.array(z.string())])
|
||||
.nullish(),
|
||||
.nullish()
|
||||
)
|
||||
.nullish(),
|
||||
usage: usage,
|
||||
@@ -131,7 +131,7 @@ export const UpdateGenerationBody = UpdateSpanBody.extend({
|
||||
z.string(),
|
||||
z
|
||||
.union([z.string(), z.number(), z.boolean(), z.array(z.string())])
|
||||
.nullish(),
|
||||
.nullish()
|
||||
)
|
||||
.nullish(),
|
||||
usage: usage,
|
||||
@@ -195,7 +195,7 @@ export const LegacyGenerationsCreateSchema = z.object({
|
||||
modelParameters: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.union([z.string(), z.number(), z.boolean()]).nullish(),
|
||||
z.union([z.string(), z.number(), z.boolean()]).nullish()
|
||||
)
|
||||
.nullish(),
|
||||
prompt: jsonSchema.nullish(),
|
||||
@@ -219,7 +219,7 @@ export const LegacyGenerationPatchSchema = z.object({
|
||||
modelParameters: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.union([z.string(), z.number(), z.boolean()]).nullish(),
|
||||
z.union([z.string(), z.number(), z.boolean()]).nullish()
|
||||
)
|
||||
.nullish(),
|
||||
prompt: jsonSchema.nullish(),
|
||||
@@ -243,7 +243,7 @@ export const LegacyObservationBody = z.object({
|
||||
modelParameters: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.union([z.string(), z.number(), z.boolean()]).nullish(),
|
||||
z.union([z.string(), z.number(), z.boolean()]).nullish()
|
||||
)
|
||||
.nullish(),
|
||||
input: jsonSchema.nullish(),
|
||||
@@ -9,6 +9,9 @@ export * from "./filterToPrisma";
|
||||
export * from "./tracesTable";
|
||||
export * from "./server/auth";
|
||||
export * from "./observationsTable";
|
||||
export * from "./features/ingestion/types";
|
||||
export * from "./utils/zod";
|
||||
export * from "./utils/json";
|
||||
export { env } from "./env";
|
||||
|
||||
// llm api
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { compare, hash } from "bcryptjs";
|
||||
import { randomUUID } from "crypto";
|
||||
import * as crypto from "crypto";
|
||||
import type { OAuthConfig, OAuthUserConfig } from "next-auth/providers/oauth";
|
||||
|
||||
export function generateSecretKey() {
|
||||
return `sk-lf-${randomUUID()}`;
|
||||
@@ -48,3 +49,33 @@ export function createShaHash(privateKey: string, salt: string): string {
|
||||
|
||||
return hash;
|
||||
}
|
||||
|
||||
export interface CustomSSOUser extends Record<string, any> {
|
||||
email: string;
|
||||
id: string;
|
||||
name: string;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
export function CustomSSOProvider<P extends CustomSSOUser>(
|
||||
options: OAuthUserConfig<P>
|
||||
): OAuthConfig<P> {
|
||||
return {
|
||||
id: "custom",
|
||||
name: "CustomSSOProvider",
|
||||
type: "oauth",
|
||||
wellKnown: `${options.issuer}/.well-known/openid-configuration`,
|
||||
authorization: { params: { scope: "openid email profile" } },
|
||||
checks: ["pkce", "state"],
|
||||
idToken: true,
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.sub,
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
image: null,
|
||||
};
|
||||
},
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
ChatMessageRole,
|
||||
LLMFunctionCall,
|
||||
ModelParams,
|
||||
ModelProvider,
|
||||
LLMAdapter,
|
||||
} from "./types";
|
||||
import zodToJsonSchema from "zod-to-json-schema";
|
||||
import { JsonOutputFunctionsParser } from "langchain/output_parsers";
|
||||
@@ -27,7 +27,9 @@ type LLMCompletionParams = {
|
||||
modelParams: ModelParams;
|
||||
functionCall?: LLMFunctionCall;
|
||||
callbacks?: BaseCallbackHandler[];
|
||||
baseURL?: string;
|
||||
apiKey?: string;
|
||||
maxRetries?: number;
|
||||
};
|
||||
|
||||
type FetchLLMCompletionParams = LLMCompletionParams & {
|
||||
@@ -57,7 +59,16 @@ export async function fetchLLMCompletion(
|
||||
params: FetchLLMCompletionParams
|
||||
): Promise<string | IterableReadableStream<Uint8Array> | unknown> {
|
||||
// the apiKey must never be printed to the console
|
||||
const { messages, modelParams, streaming, callbacks, apiKey } = params;
|
||||
const {
|
||||
messages,
|
||||
modelParams,
|
||||
streaming,
|
||||
callbacks,
|
||||
apiKey,
|
||||
baseURL,
|
||||
maxRetries,
|
||||
} = params;
|
||||
|
||||
const finalMessages = messages.map((message) => {
|
||||
if (message.role === ChatMessageRole.User)
|
||||
return new HumanMessage(message.content);
|
||||
@@ -68,16 +79,18 @@ export async function fetchLLMCompletion(
|
||||
});
|
||||
|
||||
let chatModel: ChatOpenAI | ChatAnthropic;
|
||||
if (modelParams.provider === ModelProvider.Anthropic) {
|
||||
if (modelParams.adapter === LLMAdapter.Anthropic) {
|
||||
chatModel = new ChatAnthropic({
|
||||
anthropicApiKey: apiKey,
|
||||
anthropicApiUrl: baseURL,
|
||||
modelName: modelParams.model,
|
||||
temperature: modelParams.temperature,
|
||||
maxTokens: modelParams.max_tokens,
|
||||
topP: modelParams.top_p,
|
||||
callbacks,
|
||||
clientOptions: { maxRetries },
|
||||
});
|
||||
} else if (modelParams.provider === ModelProvider.OpenAI) {
|
||||
} else if (modelParams.adapter === LLMAdapter.OpenAI) {
|
||||
chatModel = new ChatOpenAI({
|
||||
openAIApiKey: apiKey,
|
||||
modelName: modelParams.model,
|
||||
@@ -85,15 +98,29 @@ export async function fetchLLMCompletion(
|
||||
maxTokens: modelParams.max_tokens,
|
||||
topP: modelParams.top_p,
|
||||
callbacks,
|
||||
maxRetries,
|
||||
configuration: {
|
||||
baseURL,
|
||||
},
|
||||
});
|
||||
} else if (modelParams.adapter === LLMAdapter.Azure) {
|
||||
chatModel = new ChatOpenAI({
|
||||
azureOpenAIApiKey: apiKey,
|
||||
azureOpenAIBasePath: baseURL,
|
||||
azureOpenAIApiDeploymentName: modelParams.model,
|
||||
azureOpenAIApiVersion: "2024-02-01",
|
||||
temperature: modelParams.temperature,
|
||||
maxTokens: modelParams.max_tokens,
|
||||
topP: modelParams.top_p,
|
||||
callbacks,
|
||||
maxRetries,
|
||||
});
|
||||
} else {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const _exhaustiveCheck: never = modelParams;
|
||||
const _exhaustiveCheck: never = modelParams.adapter;
|
||||
throw new Error("This model provider is not supported.");
|
||||
}
|
||||
|
||||
console.log("Making LLM call with params: ", modelParams);
|
||||
|
||||
if (params.functionCall) {
|
||||
const functionCallingModel = chatModel.bind({
|
||||
functions: [
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LlmApiKeys } from "@prisma/client";
|
||||
import z from "zod";
|
||||
|
||||
export type PromptVariable = { name: string; value: string; isUsed: boolean };
|
||||
@@ -9,9 +10,10 @@ export type ChatMessage = {
|
||||
|
||||
export type ChatMessageWithId = ChatMessage & { id: string };
|
||||
|
||||
export enum ModelProvider {
|
||||
export enum LLMAdapter {
|
||||
Anthropic = "anthropic",
|
||||
OpenAI = "openai",
|
||||
Azure = "azure",
|
||||
}
|
||||
|
||||
export enum ChatMessageRole {
|
||||
@@ -19,32 +21,30 @@ export enum ChatMessageRole {
|
||||
User = "user",
|
||||
Assistant = "assistant",
|
||||
}
|
||||
export type ModelParams = AnthropicModelParams | OpenAIModelParams;
|
||||
|
||||
export type ModelParams = {
|
||||
provider: string;
|
||||
adapter: LLMAdapter;
|
||||
model: string;
|
||||
} & ModelConfig;
|
||||
|
||||
type RecordWithEnabledFlag<T> = {
|
||||
[K in keyof T]: { value: T[K]; enabled: boolean };
|
||||
};
|
||||
export type UIModelParams = RecordWithEnabledFlag<
|
||||
Required<AnthropicModelParams | OpenAIModelParams> & {
|
||||
Required<ModelParams> & {
|
||||
maxTemperature: number;
|
||||
}
|
||||
>;
|
||||
|
||||
// Generic config
|
||||
export type ModelConfig = z.infer<typeof ZodModelConfig>;
|
||||
|
||||
export const ZodModelConfig = z.object({
|
||||
max_tokens: z.coerce.number().optional(),
|
||||
temperature: z.coerce.number().optional(),
|
||||
top_p: z.coerce.number().optional(),
|
||||
});
|
||||
|
||||
// OpenAI
|
||||
export type OpenAIModelParams = {
|
||||
provider: ModelProvider.OpenAI;
|
||||
model: OpenAIModel;
|
||||
} & ModelConfig;
|
||||
|
||||
export const openAIModels = [
|
||||
"gpt-4o",
|
||||
"gpt-4o-2024-05-13",
|
||||
@@ -64,12 +64,6 @@ export const openAIModels = [
|
||||
|
||||
export type OpenAIModel = (typeof openAIModels)[number];
|
||||
|
||||
// Anthropic
|
||||
export type AnthropicModelParams = {
|
||||
provider: ModelProvider.Anthropic;
|
||||
model: AnthropicModel;
|
||||
} & ModelConfig;
|
||||
|
||||
export const anthropicModels = [
|
||||
"claude-3-opus-20240229",
|
||||
"claude-3-sonnet-20240229",
|
||||
@@ -81,8 +75,9 @@ export const anthropicModels = [
|
||||
|
||||
export type AnthropicModel = (typeof anthropicModels)[number];
|
||||
export const supportedModels = {
|
||||
[ModelProvider.Anthropic]: anthropicModels,
|
||||
[ModelProvider.OpenAI]: openAIModels,
|
||||
[LLMAdapter.Anthropic]: anthropicModels,
|
||||
[LLMAdapter.OpenAI]: openAIModels,
|
||||
[LLMAdapter.Azure]: [],
|
||||
} as const;
|
||||
|
||||
export type LLMFunctionCall = {
|
||||
@@ -90,3 +85,26 @@ export type LLMFunctionCall = {
|
||||
description: string;
|
||||
parameters: z.ZodTypeAny; // this has to be a json schema for OpenAI
|
||||
};
|
||||
|
||||
export const LLMApiKeySchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
projectId: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
adapter: z.nativeEnum(LLMAdapter),
|
||||
provider: z.string(),
|
||||
displaySecretKey: z.string(),
|
||||
secretKey: z.string(),
|
||||
baseURL: z.string().nullable(),
|
||||
customModels: z.array(z.string()),
|
||||
withDefaultModels: z.boolean(),
|
||||
})
|
||||
// strict mode to prevent extra keys. Thorws error otherwise
|
||||
// https://github.com/colinhacks/zod?tab=readme-ov-file#strict
|
||||
.strict();
|
||||
|
||||
export type LLMApiKey =
|
||||
z.infer<typeof LLMApiKeySchema> extends LlmApiKeys
|
||||
? z.infer<typeof LLMApiKeySchema>
|
||||
: never;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type jsonSchema } from "@/src/utils/zod";
|
||||
import { type z } from "zod";
|
||||
import lodash from "lodash";
|
||||
import { jsonSchema } from "./zod";
|
||||
|
||||
export const parseJson = (input: string) => {
|
||||
try {
|
||||
@@ -37,7 +37,7 @@ export function deepParseJson(json: unknown): unknown {
|
||||
// Ensure we only iterate over the object's own properties
|
||||
if (Object.prototype.hasOwnProperty.call(json, key)) {
|
||||
(json as Record<string, unknown>)[key] = deepParseJson(
|
||||
(json as Record<string, unknown>)[key],
|
||||
(json as Record<string, unknown>)[key]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ export function deepParseJson(json: unknown): unknown {
|
||||
|
||||
export const mergeJson = (
|
||||
json1?: z.infer<typeof jsonSchema>,
|
||||
json2?: z.infer<typeof jsonSchema>,
|
||||
json2?: z.infer<typeof jsonSchema>
|
||||
) => {
|
||||
if (json1 === undefined) {
|
||||
return json2;
|
||||
@@ -26,7 +26,7 @@ const jsonSchemaNullable: z.ZodType<JsonNested> = z.lazy(() =>
|
||||
nestedLiteralSchema,
|
||||
z.array(jsonSchemaNullable),
|
||||
z.record(jsonSchemaNullable),
|
||||
]),
|
||||
])
|
||||
);
|
||||
|
||||
// Root schema that does not allow nulls at the root level
|
||||
@@ -35,17 +35,17 @@ export const jsonSchema: z.ZodType<Json> = z.lazy(() =>
|
||||
rootLiteralSchema,
|
||||
z.array(jsonSchemaNullable),
|
||||
z.record(jsonSchemaNullable),
|
||||
]),
|
||||
])
|
||||
);
|
||||
|
||||
export const paginationZod = {
|
||||
page: z.preprocess(
|
||||
(x) => (x === "" ? undefined : x),
|
||||
z.coerce.number().default(1),
|
||||
z.coerce.number().default(1)
|
||||
),
|
||||
limit: z.preprocess(
|
||||
(x) => (x === "" ? undefined : x),
|
||||
z.coerce.number().lte(100).default(50),
|
||||
z.coerce.number().lte(100).default(50)
|
||||
),
|
||||
};
|
||||
|
||||
Generated
+2179
-978
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -9,7 +9,9 @@
|
||||
"start": {
|
||||
"dependsOn": ["^start"]
|
||||
},
|
||||
"db:migrate": {},
|
||||
"db:migrate": {
|
||||
"cache": false
|
||||
},
|
||||
"db:deploy": {},
|
||||
"db:reset": {},
|
||||
"db:push": {},
|
||||
|
||||
+3
-2
@@ -1,8 +1,8 @@
|
||||
FROM node:20-alpine AS alpine
|
||||
FROM node:20-alpine3.20 AS alpine
|
||||
|
||||
# It's important to update the index before installing packages to ensure you're getting the latest versions.
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk update && apk upgrade --no-cache libcrypto3 libssl3 libc6-compat
|
||||
RUN apk update && apk upgrade --no-cache libcrypto3 libssl3 libc6-compat busybox ssl_client
|
||||
|
||||
FROM alpine AS base
|
||||
RUN npm install turbo@^1.13.3 --global
|
||||
@@ -78,6 +78,7 @@ COPY --from=builder --chown=nextjs:nodejs /app/web/public ./web/public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/packages/shared/prisma ./packages/shared/prisma
|
||||
|
||||
COPY --chown=nextjs:nodejs ./web/entrypoint.sh ./web/entrypoint.sh
|
||||
COPY --chown=nextjs:nodejs ./packages/shared/scripts/cleanup.sql ./packages/shared/scripts/cleanup.sql
|
||||
RUN chmod +x ./web/entrypoint.sh
|
||||
|
||||
USER nextjs
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Run cleanup script before running migrations
|
||||
# Check if DATABASE_URL is not set
|
||||
if [ -z "$DATABASE_URL" ]; then
|
||||
# Check if all required variables are provided
|
||||
@@ -18,6 +19,8 @@ if [ -z "$DIRECT_URL" ]; then
|
||||
export DIRECT_URL=$DATABASE_URL
|
||||
fi
|
||||
|
||||
prisma db execute --url "$DIRECT_URL" --file "./packages/shared/scripts/cleanup.sql"
|
||||
|
||||
# Apply migrations
|
||||
prisma migrate deploy --schema=./packages/shared/prisma/schema.prisma
|
||||
status=$?
|
||||
|
||||
+15
-15
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "2.48.0",
|
||||
"version": "2.54.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -27,7 +27,7 @@
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lint": "^6.8.0",
|
||||
"@headlessui/react": "1.7.18",
|
||||
"@headlessui/tailwindcss": "0.2.0",
|
||||
"@headlessui/tailwindcss": "0.2.1",
|
||||
"@heroicons/react": "^2.1.3",
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@langchain/anthropic": "^0.1.21",
|
||||
@@ -36,6 +36,7 @@
|
||||
"@langfuse/ee": "workspace:*",
|
||||
"@langfuse/shared": "workspace:*",
|
||||
"@marsidev/react-turnstile": "^0.5.4",
|
||||
"@mui/x-tree-view": "^7.6.2",
|
||||
"@next-auth/prisma-adapter": "^1.0.7",
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||
@@ -58,27 +59,26 @@
|
||||
"@radix-ui/react-toggle": "^1.0.3",
|
||||
"@radix-ui/react-toggle-group": "^1.0.4",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@react-email/components": "^0.0.18",
|
||||
"@react-email/render": "^0.0.14",
|
||||
"@react-email/components": "^0.0.19",
|
||||
"@react-email/render": "^0.0.15",
|
||||
"@remixicon/react": "^4.2.0",
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@sentry/nextjs": "^7.113.0",
|
||||
"@sentry/node": "^7.113.0",
|
||||
"@sentry/profiling-node": "^7.113.0",
|
||||
"@sentry/types": "^7.113.0",
|
||||
"@sentry/nextjs": "^8.8.0",
|
||||
"@sentry/node": "^8.8.0",
|
||||
"@sentry/profiling-node": "^8.8.0",
|
||||
"@sentry/types": "^8.8.0",
|
||||
"@t3-oss/env-nextjs": "^0.10.1",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
"@tanstack/react-table": "^8.11.8",
|
||||
"@tanstack/react-virtual": "^3.5.0",
|
||||
"@tremor/react": "3.16.2",
|
||||
"@trpc/client": "^10.45.0",
|
||||
"@trpc/next": "^10.45.0",
|
||||
"@trpc/react-query": "^10.45.0",
|
||||
"@trpc/server": "^10.45.0",
|
||||
"@uiw/codemirror-theme-github": "^4.21.25",
|
||||
"@uiw/codemirror-theme-tokyo-night": "^4.22.1",
|
||||
"@uiw/codemirror-theme-tokyo-night": "^4.22.2",
|
||||
"@uiw/react-codemirror": "^4.21.25",
|
||||
"ai": "^3.0.23",
|
||||
"bcryptjs": "^2.4.3",
|
||||
@@ -89,7 +89,7 @@
|
||||
"cors": "^2.8.5",
|
||||
"date-fns": "^3.3.1",
|
||||
"decimal.js": "^10.4.3",
|
||||
"dompurify": "^3.1.4",
|
||||
"dompurify": "^3.1.5",
|
||||
"exponential-backoff": "^3.1.1",
|
||||
"js-tiktoken": "^1.0.12",
|
||||
"kysely": "^0.27.3",
|
||||
@@ -125,21 +125,21 @@
|
||||
"@mermaid-js/mermaid-cli": "^10.7.0",
|
||||
"@playwright/test": "^1.43.1",
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@testing-library/jest-dom": "^6.4.5",
|
||||
"@testing-library/jest-dom": "^6.4.6",
|
||||
"@testing-library/react": "^15.0.7",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/eslint": "^8.56.7",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/lodash": "^4.17.4",
|
||||
"@types/lodash": "^4.17.5",
|
||||
"@types/node": "20.10.5",
|
||||
"@types/nodemailer": "^6.4.15",
|
||||
"@types/react": "^18.2.79",
|
||||
"@types/react-dom": "^18.2.25",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^7.7.0",
|
||||
"@typescript-eslint/parser": "^7.12.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"eslint": "^8.56.0",
|
||||
@@ -150,7 +150,7 @@
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-tailwindcss": "^0.5.14",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.7.2",
|
||||
|
||||
@@ -397,7 +397,10 @@ paths:
|
||||
description: ''
|
||||
/api/public/ingestion:
|
||||
post:
|
||||
description: Batched ingestion for Langfuse Tracing
|
||||
description: >-
|
||||
Batched ingestion for Langfuse Tracing. If you want to use tracing via
|
||||
the API, such as to build your own Langfuse client implementation, this
|
||||
is the only API route you need to implement.
|
||||
operationId: ingestion_batch
|
||||
tags:
|
||||
- Ingestion
|
||||
@@ -446,6 +449,14 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/IngestionEvent'
|
||||
description: >-
|
||||
Batch of tracing events to be ingested. Discriminated by
|
||||
attribute `type`.
|
||||
metadata:
|
||||
nullable: true
|
||||
description: >-
|
||||
Optional. Metadata field used by the Langfuse SDKs for
|
||||
debugging.
|
||||
required:
|
||||
- batch
|
||||
/api/public/metrics/daily:
|
||||
@@ -997,9 +1008,10 @@ paths:
|
||||
- name: source
|
||||
in: query
|
||||
description: Retrieve only scores from a specific source.
|
||||
required: true
|
||||
required: false
|
||||
schema:
|
||||
$ref: '#/components/schemas/ScoreSource'
|
||||
nullable: true
|
||||
- name: operator
|
||||
in: query
|
||||
description: Retrieve only scores with <operator> value.
|
||||
@@ -1015,6 +1027,13 @@ paths:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
- name: scoreIds
|
||||
in: query
|
||||
description: Comma-separated list of score IDs to limit the results to.
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
nullable: true
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
@@ -1964,8 +1983,19 @@ components:
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
- event-create
|
||||
- $ref: '#/components/schemas/CreateEventEvent'
|
||||
- span-create
|
||||
- $ref: '#/components/schemas/CreateSpanEvent'
|
||||
required:
|
||||
- type
|
||||
- type: object
|
||||
allOf:
|
||||
- type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
- span-update
|
||||
- $ref: '#/components/schemas/UpdateSpanEvent'
|
||||
required:
|
||||
- type
|
||||
- type: object
|
||||
@@ -1997,19 +2027,8 @@ components:
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
- span-create
|
||||
- $ref: '#/components/schemas/CreateSpanEvent'
|
||||
required:
|
||||
- type
|
||||
- type: object
|
||||
allOf:
|
||||
- type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
- span-update
|
||||
- $ref: '#/components/schemas/UpdateSpanEvent'
|
||||
- event-create
|
||||
- $ref: '#/components/schemas/CreateEventEvent'
|
||||
required:
|
||||
- type
|
||||
- type: object
|
||||
@@ -2332,13 +2351,20 @@ components:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: UUID v4 that identifies the event
|
||||
timestamp:
|
||||
type: string
|
||||
metadata: {}
|
||||
description: >-
|
||||
Datetime (ISO 8601) of event creation in client. Should be as close
|
||||
to actual event creation in client as possible, this timestamp will
|
||||
be used for ordering of events in future release. Resolution:
|
||||
milliseconds (required), microseconds (optimal).
|
||||
metadata:
|
||||
nullable: true
|
||||
description: Optional. Metadata field used by the Langfuse SDKs for debugging.
|
||||
required:
|
||||
- id
|
||||
- timestamp
|
||||
- metadata
|
||||
TraceEvent:
|
||||
title: TraceEvent
|
||||
type: object
|
||||
|
||||
@@ -337,7 +337,7 @@
|
||||
"_type": "endpoint",
|
||||
"name": "Batch",
|
||||
"request": {
|
||||
"description": "Batched ingestion for Langfuse Tracing",
|
||||
"description": "Batched ingestion for Langfuse Tracing. If you want to use tracing via the API, such as to build your own Langfuse client implementation, this is the only API route you need to implement.",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/ingestion",
|
||||
"host": [
|
||||
@@ -356,7 +356,7 @@
|
||||
"auth": null,
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"batch\": [\n {\n \"type\": \"trace-create\",\n \"body\": {\n \"id\": \"example\",\n \"timestamp\": \"1994-11-05T13:15:30Z\",\n \"name\": \"example\",\n \"userId\": \"example\",\n \"input\": \"UNKNOWN\",\n \"output\": \"UNKNOWN\",\n \"sessionId\": \"example\",\n \"release\": \"example\",\n \"version\": \"example\",\n \"metadata\": \"UNKNOWN\",\n \"tags\": [\n \"example\"\n ],\n \"public\": true\n },\n \"id\": \"example\",\n \"timestamp\": \"example\",\n \"metadata\": \"UNKNOWN\"\n }\n ]\n}",
|
||||
"raw": "{\n \"batch\": [\n {\n \"type\": \"trace-create\",\n \"body\": {\n \"id\": \"example\",\n \"timestamp\": \"1994-11-05T13:15:30Z\",\n \"name\": \"example\",\n \"userId\": \"example\",\n \"input\": \"UNKNOWN\",\n \"output\": \"UNKNOWN\",\n \"sessionId\": \"example\",\n \"release\": \"example\",\n \"version\": \"example\",\n \"metadata\": \"UNKNOWN\",\n \"tags\": [\n \"example\"\n ],\n \"public\": true\n },\n \"id\": \"example\",\n \"timestamp\": \"example\",\n \"metadata\": \"UNKNOWN\"\n }\n ],\n \"metadata\": \"UNKNOWN\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
@@ -755,7 +755,7 @@
|
||||
"request": {
|
||||
"description": "Get a list of scores",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/scores?page=&limit=&userId=&name=&fromTimestamp=&source=&operator=&value=",
|
||||
"raw": "{{baseUrl}}/api/public/scores?page=&limit=&userId=&name=&fromTimestamp=&source=&operator=&value=&scoreIds=",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
@@ -804,6 +804,11 @@
|
||||
"key": "value",
|
||||
"value": "",
|
||||
"description": "Retrieve only scores with <operator> value."
|
||||
},
|
||||
{
|
||||
"key": "scoreIds",
|
||||
"value": "",
|
||||
"description": "Comma-separated list of score IDs to limit the results to."
|
||||
}
|
||||
],
|
||||
"variable": []
|
||||
|
||||
@@ -4,7 +4,7 @@ if (process.env.NEXT_PUBLIC_SENTRY_DSN)
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
// Replay may only be enabled for the client-side
|
||||
integrations: [new Sentry.Replay()],
|
||||
integrations: [Sentry.replayIntegration()],
|
||||
|
||||
// Set tracesSampleRate to 1.0 to capture 100%
|
||||
// of transactions for performance monitoring.
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { ProfilingIntegration } from "@sentry/profiling-node";
|
||||
import type { SamplingContext, TransactionEvent } from "@sentry/types";
|
||||
import { jsonSchema } from "@/src/utils/zod";
|
||||
import { jsonSchema } from "@langfuse/shared";
|
||||
import { nodeProfilingIntegration } from "@sentry/profiling-node";
|
||||
|
||||
if (process.env.NEXT_PUBLIC_SENTRY_DSN)
|
||||
Sentry.init({
|
||||
@@ -12,7 +10,7 @@ if (process.env.NEXT_PUBLIC_SENTRY_DSN)
|
||||
// Set tracesSampleRate to 1.0 to capture 100%
|
||||
// of transactions for performance monitoring.
|
||||
// We recommend adjusting this value in production
|
||||
tracesSampler: (samplingContext: SamplingContext) => {
|
||||
tracesSampler: (samplingContext) => {
|
||||
if (
|
||||
samplingContext.request &&
|
||||
samplingContext.request.url &&
|
||||
@@ -23,9 +21,7 @@ if (process.env.NEXT_PUBLIC_SENTRY_DSN)
|
||||
if (
|
||||
samplingContext.request &&
|
||||
samplingContext.request.url &&
|
||||
samplingContext.request.url.includes("api/auth") &&
|
||||
samplingContext.transactionContext.status !== "ok" &&
|
||||
samplingContext.transactionContext.status !== "unauthenticated"
|
||||
samplingContext.request.url.includes("api/auth")
|
||||
) {
|
||||
return 0.1;
|
||||
}
|
||||
@@ -35,14 +31,13 @@ if (process.env.NEXT_PUBLIC_SENTRY_DSN)
|
||||
profilesSampleRate: 0.1,
|
||||
integrations: [
|
||||
// Add profiling integration to list of integrations
|
||||
new ProfilingIntegration(),
|
||||
new Sentry.Integrations.Prisma({ client: prisma }),
|
||||
Sentry.metrics.metricsAggregatorIntegration(),
|
||||
Sentry.prismaIntegration(),
|
||||
nodeProfilingIntegration(),
|
||||
],
|
||||
|
||||
// filter out passwords from the signup request body
|
||||
// transaction events are sentry transactions which include logs and spans.
|
||||
beforeSendTransaction(transaction: TransactionEvent) {
|
||||
beforeSendTransaction(transaction) {
|
||||
if (
|
||||
transaction.request &&
|
||||
typeof transaction.request.data === "string" &&
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import type { Session } from "next-auth";
|
||||
import { pruneDatabase } from "@/src/__tests__/test-utils";
|
||||
import { ModelProvider } from "@langfuse/shared";
|
||||
import { LLMAdapter } from "@langfuse/shared";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { appRouter } from "@/src/server/api/root";
|
||||
import { createInnerTRPCContext } from "@/src/server/api/trpc";
|
||||
@@ -38,11 +38,20 @@ describe("llmApiKey.all RPC", () => {
|
||||
|
||||
it("should create an llm api key", async () => {
|
||||
const secret = "test-secret";
|
||||
const provider = "openai";
|
||||
const adapter = LLMAdapter.OpenAI;
|
||||
const customModels = ["fancy-gpt-3.5-turbo"];
|
||||
const baseURL = "https://custom.openai.com/v1";
|
||||
const withDefaultModels = false;
|
||||
|
||||
await caller.llmApiKey.create({
|
||||
projectId,
|
||||
secretKey: secret,
|
||||
provider: ModelProvider.OpenAI,
|
||||
provider,
|
||||
adapter,
|
||||
baseURL,
|
||||
customModels,
|
||||
withDefaultModels,
|
||||
});
|
||||
|
||||
const llmApiKeys = await prisma.llmApiKeys.findMany({
|
||||
@@ -55,18 +64,31 @@ describe("llmApiKey.all RPC", () => {
|
||||
expect(llmApiKeys[0].projectId).toBe(projectId);
|
||||
expect(llmApiKeys[0].secretKey).not.toBeNull();
|
||||
expect(llmApiKeys[0].secretKey).not.toEqual(secret);
|
||||
expect(llmApiKeys[0].provider).toBe(ModelProvider.OpenAI);
|
||||
expect(llmApiKeys[0].provider).toBe(provider);
|
||||
expect(llmApiKeys[0].adapter).toBe(adapter);
|
||||
expect(llmApiKeys[0].baseURL).toBe(baseURL);
|
||||
expect(llmApiKeys[0].customModels).toEqual(customModels);
|
||||
expect(llmApiKeys[0].withDefaultModels).toBe(withDefaultModels);
|
||||
// this has to be 3 dots and the last 4 characters of the secret
|
||||
expect(llmApiKeys[0].displaySecretKey).toMatch(/^...[a-zA-Z0-9]{4}$/);
|
||||
});
|
||||
|
||||
it("should create and get an llm api key", async () => {
|
||||
const secret = "test-secret";
|
||||
const provider = "openai";
|
||||
const adapter = LLMAdapter.OpenAI;
|
||||
const customModels = ["fancy-gpt-3.5-turbo"];
|
||||
const baseURL = "https://custom.openai.com/v1";
|
||||
const withDefaultModels = false;
|
||||
|
||||
await caller.llmApiKey.create({
|
||||
projectId,
|
||||
secretKey: secret,
|
||||
provider: ModelProvider.OpenAI,
|
||||
provider,
|
||||
adapter,
|
||||
baseURL,
|
||||
customModels,
|
||||
withDefaultModels,
|
||||
});
|
||||
|
||||
const dbLlmApiKeys = await prisma.llmApiKeys.findMany({
|
||||
@@ -77,16 +99,23 @@ describe("llmApiKey.all RPC", () => {
|
||||
|
||||
expect(dbLlmApiKeys.length).toBe(1);
|
||||
|
||||
const llmApiKeys = await caller.llmApiKey.all({
|
||||
const { data: llmApiKeys } = await caller.llmApiKey.all({
|
||||
projectId,
|
||||
});
|
||||
|
||||
expect(llmApiKeys.data.length).toBe(1);
|
||||
expect(llmApiKeys.data[0].provider).toBe(ModelProvider.OpenAI);
|
||||
expect(llmApiKeys.length).toBe(1);
|
||||
expect(llmApiKeys[0].projectId).toBe(projectId);
|
||||
expect(llmApiKeys[0].secretKey).not.toBeNull();
|
||||
expect(llmApiKeys[0].secretKey).not.toEqual(secret);
|
||||
expect(llmApiKeys[0].provider).toBe(provider);
|
||||
expect(llmApiKeys[0].adapter).toBe(adapter);
|
||||
expect(llmApiKeys[0].baseURL).toBe(baseURL);
|
||||
expect(llmApiKeys[0].customModels).toEqual(customModels);
|
||||
expect(llmApiKeys[0].withDefaultModels).toBe(withDefaultModels);
|
||||
// this has to be 3 dots and the last 4 characters of the secret
|
||||
expect(llmApiKeys.data[0].displaySecretKey).toMatch(/^...[a-zA-Z0-9]{4}$/);
|
||||
expect(llmApiKeys[0].displaySecretKey).toMatch(/^...[a-zA-Z0-9]{4}$/);
|
||||
|
||||
// response must not contain the secret key itself
|
||||
expect(llmApiKeys.data[0]).not.toHaveProperty("secretKey");
|
||||
expect(llmApiKeys[0]).not.toHaveProperty("secretKey");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -591,5 +591,37 @@ describe("/api/public/scores API Endpoint", () => {
|
||||
message: "Invalid request data",
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter scores by score IDs", async () => {
|
||||
const getScore = await makeAPICall<{
|
||||
data: [
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
value: number;
|
||||
},
|
||||
];
|
||||
meta: object;
|
||||
}>("GET", `/api/public/scores?scoreIds=${scoreId_1},${scoreId_2}`);
|
||||
expect(getScore.status).toBe(200);
|
||||
expect(getScore.body.meta).toMatchObject({
|
||||
page: 1,
|
||||
limit: 50,
|
||||
totalItems: 2,
|
||||
totalPages: 1,
|
||||
});
|
||||
expect(getScore.body.data).toMatchObject([
|
||||
{
|
||||
id: scoreId_2,
|
||||
name: scoreName,
|
||||
value: 50.5,
|
||||
},
|
||||
{
|
||||
id: scoreId_1,
|
||||
name: scoreName,
|
||||
value: 10.5,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { paginationZod } from "@/src/utils/zod";
|
||||
import { paginationZod } from "@langfuse/shared";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
// Create test cases
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import chatCompletionHandler from "@/src/ee/features/playground/server/chatCompletionHandler";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 60;
|
||||
export const maxDuration = 120;
|
||||
|
||||
export const POST = chatCompletionHandler;
|
||||
|
||||
@@ -1,8 +1,124 @@
|
||||
import { AlertTriangle, Check } from "lucide-react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
BadgeCheck,
|
||||
Check,
|
||||
Github,
|
||||
HardDriveDownload,
|
||||
Map,
|
||||
Newspaper,
|
||||
} from "lucide-react";
|
||||
|
||||
import { VERSION } from "@/src/constants";
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/src/components/ui/dropdown-menu";
|
||||
import { ArrowUp } from "lucide-react";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { useIsEeEnabled } from "@/src/ee/utils/useIsEeEnabled";
|
||||
|
||||
const VersionLabel = ({ className }: { className?: string }) => {
|
||||
const checkUpdate = api.public.checkUpdate.useQuery(undefined, {
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
enabled: !env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION, // do not check for updates on Langfuse Cloud
|
||||
onError: (error) => console.error("checkUpdate error", error), // do not render default error message
|
||||
});
|
||||
const isEeVersion =
|
||||
useIsEeEnabled() && !Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION);
|
||||
|
||||
const hasUpdate =
|
||||
!env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION &&
|
||||
checkUpdate.data &&
|
||||
checkUpdate.data.updateType;
|
||||
|
||||
const color =
|
||||
checkUpdate.data?.updateType === "major"
|
||||
? "text-dark-red"
|
||||
: checkUpdate.data?.updateType === "minor"
|
||||
? "text-dark-yellow"
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="xs" className={className}>
|
||||
{VERSION}
|
||||
{hasUpdate && <ArrowUp className={`ml-1 h-3 w-3 ${color}`} />}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{hasUpdate ? (
|
||||
<>
|
||||
<DropdownMenuLabel>
|
||||
New {checkUpdate.data?.updateType} version:{" "}
|
||||
{checkUpdate.data?.latestRelease}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
) : !env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION ? (
|
||||
<>
|
||||
<DropdownMenuLabel>This is the latest release</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
) : null}
|
||||
{isEeVersion && (
|
||||
<>
|
||||
<DropdownMenuLabel className="flex items-center font-normal">
|
||||
<BadgeCheck size={16} className="mr-2" />
|
||||
Enterprise Edition
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href="https://github.com/langfuse/langfuse/releases"
|
||||
target="_blank"
|
||||
>
|
||||
<Github size={16} className="mr-2" />
|
||||
Releases
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="https://langfuse.com/changelog" target="_blank">
|
||||
<Newspaper size={16} className="mr-2" />
|
||||
Changelog
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="https://langfuse.com/roadmap" target="_blank">
|
||||
<Map size={16} className="mr-2" />
|
||||
Roadmap
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{hasUpdate && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href="https://langfuse.com/docs/deployment/self-host#update"
|
||||
target="_blank"
|
||||
>
|
||||
<HardDriveDownload size={16} className="mr-2" />
|
||||
Update
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export const LangfuseIcon = ({
|
||||
size = 32,
|
||||
@@ -65,22 +181,12 @@ export const LangfuseLogo = ({
|
||||
<span
|
||||
className={cn(
|
||||
"ml-2 font-mono font-semibold",
|
||||
size === "sm" ? "text-sm" : "text-xl",
|
||||
size === "sm" ? "text-sm" : "text-lg",
|
||||
)}
|
||||
>
|
||||
Langfuse
|
||||
</span>
|
||||
{version && (
|
||||
<a
|
||||
href="https://github.com/langfuse/langfuse/releases"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
title="View releases on GitHub"
|
||||
className="ml-2 text-xs text-muted-foreground"
|
||||
>
|
||||
{VERSION}
|
||||
</a>
|
||||
)}
|
||||
{version && <VersionLabel className="ml-2" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import Link from "next/link";
|
||||
import { Label } from "@/src/components/ui/label";
|
||||
import { useHasAccess } from "@/src/features/rbac/utils/checkAccess";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { supportedModels, type UIModelParams } from "@langfuse/shared";
|
||||
import { type UIModelParams } from "@langfuse/shared";
|
||||
import { ArrowTopRightIcon } from "@radix-ui/react-icons";
|
||||
|
||||
export const LLMApiKeyComponent = (p: {
|
||||
@@ -39,11 +39,7 @@ export const LLMApiKeyComponent = (p: {
|
||||
);
|
||||
}
|
||||
|
||||
const model = p.modelParams.model.value;
|
||||
const modelProvider = Object.entries(supportedModels).find((providerData) =>
|
||||
(providerData[1] as any as string[]).includes(model),
|
||||
)?.[0];
|
||||
|
||||
const modelProvider = p.modelParams.provider.value;
|
||||
const apiKey = apiKeys.data?.data.find((k) => k.provider === modelProvider);
|
||||
|
||||
return (
|
||||
@@ -51,9 +47,11 @@ export const LLMApiKeyComponent = (p: {
|
||||
<Label className="text-xs font-semibold">API key</Label>
|
||||
<div>
|
||||
{apiKey ? (
|
||||
<span className="mr-2 rounded-sm bg-input p-1 text-xs">
|
||||
{apiKey.displaySecretKey}
|
||||
</span>
|
||||
<Link href={`/project/${p.projectId}/settings#llm-api-keys`}>
|
||||
<span className="mr-2 rounded-sm bg-input p-1 text-xs">
|
||||
{apiKey.displaySecretKey}
|
||||
</span>
|
||||
</Link>
|
||||
) : undefined}
|
||||
</div>
|
||||
{/* Custom form message to include a link to the already existing prompt */}
|
||||
|
||||
@@ -8,11 +8,12 @@ import {
|
||||
} from "@/src/components/ui/select";
|
||||
import { Slider } from "@/src/components/ui/slider";
|
||||
import { Switch } from "@/src/components/ui/switch";
|
||||
import { CreateLLMApiKeyDialog } from "@/src/features/public-api/components/CreateLLMApiKeyDialog";
|
||||
import useProjectIdFromURL from "@/src/hooks/useProjectIdFromURL";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import {
|
||||
ModelProvider,
|
||||
supportedModels,
|
||||
type LLMAdapter,
|
||||
type supportedModels,
|
||||
type UIModelParams,
|
||||
} from "@langfuse/shared";
|
||||
|
||||
@@ -20,7 +21,8 @@ import { LLMApiKeyComponent } from "./LLMApiKeyComponent";
|
||||
|
||||
export type ModelParamsContext = {
|
||||
modelParams: UIModelParams;
|
||||
availableModels?: UIModelParams[];
|
||||
availableProviders: string[];
|
||||
availableModels: string[];
|
||||
updateModelParamValue: <Key extends keyof UIModelParams>(
|
||||
key: Key,
|
||||
value: UIModelParams[Key]["value"],
|
||||
@@ -31,6 +33,7 @@ export type ModelParamsContext = {
|
||||
|
||||
export const ModelParameters: React.FC<ModelParamsContext> = ({
|
||||
modelParams,
|
||||
availableProviders,
|
||||
availableModels,
|
||||
updateModelParamValue,
|
||||
setModelParamEnabled,
|
||||
@@ -43,76 +46,71 @@ export const ModelParameters: React.FC<ModelParamsContext> = ({
|
||||
return (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<p className="font-semibold">Model</p>
|
||||
<div className="space-y-4">
|
||||
<ModelParamsSelect
|
||||
title="Provider"
|
||||
modelParamsKey="provider"
|
||||
disabled={formDisabled}
|
||||
value={modelParams.provider.value}
|
||||
options={
|
||||
availableModels
|
||||
? [...new Set(availableModels.map((m) => m.provider.value))]
|
||||
: Object.values(ModelProvider)
|
||||
}
|
||||
updateModelParam={updateModelParamValue}
|
||||
/>
|
||||
<ModelParamsSelect
|
||||
title="Model name"
|
||||
modelParamsKey="model"
|
||||
disabled={formDisabled}
|
||||
value={modelParams.model.value}
|
||||
options={Object.values(
|
||||
availableModels
|
||||
? availableModels
|
||||
.filter(
|
||||
(m) => m.provider.value === modelParams.provider.value,
|
||||
)
|
||||
.map((m) => m.model.value)
|
||||
: supportedModels[modelParams.provider.value],
|
||||
)}
|
||||
updateModelParam={updateModelParamValue}
|
||||
/>
|
||||
<ModelParamsSlider
|
||||
title="Temperature"
|
||||
modelParamsKey="temperature"
|
||||
formDisabled={formDisabled}
|
||||
enabled={modelParams.temperature.enabled}
|
||||
setModelParamEnabled={setModelParamEnabled}
|
||||
value={modelParams.temperature.value}
|
||||
min={0}
|
||||
max={modelParams.maxTemperature.value}
|
||||
step={0.01}
|
||||
tooltip="The sampling temperature. Higher values will make the output more random, while lower values will make it more focused and deterministic."
|
||||
updateModelParam={updateModelParamValue}
|
||||
/>
|
||||
<ModelParamsSlider
|
||||
title="Output token limit"
|
||||
modelParamsKey="max_tokens"
|
||||
formDisabled={formDisabled}
|
||||
enabled={modelParams.max_tokens.enabled}
|
||||
setModelParamEnabled={setModelParamEnabled}
|
||||
value={modelParams.max_tokens.value}
|
||||
min={1}
|
||||
max={4096}
|
||||
step={1}
|
||||
tooltip="The maximum number of tokens that can be generated in the chat completion."
|
||||
updateModelParam={updateModelParamValue}
|
||||
/>
|
||||
<ModelParamsSlider
|
||||
title="Top P"
|
||||
modelParamsKey="top_p"
|
||||
formDisabled={formDisabled}
|
||||
enabled={modelParams.top_p.enabled}
|
||||
setModelParamEnabled={setModelParamEnabled}
|
||||
value={modelParams.top_p.value}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
tooltip="An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered. We generally recommend altering this or temperature but not both."
|
||||
updateModelParam={updateModelParamValue}
|
||||
/>
|
||||
<LLMApiKeyComponent {...{ projectId, modelParams }} />
|
||||
</div>
|
||||
{availableProviders.length === 0 ? (
|
||||
<>
|
||||
<p className="text-sm">No LLM API key set in project.</p>
|
||||
<CreateLLMApiKeyDialog />
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<ModelParamsSelect
|
||||
title="Provider"
|
||||
modelParamsKey="provider"
|
||||
disabled={formDisabled}
|
||||
value={modelParams.provider.value}
|
||||
options={availableProviders}
|
||||
updateModelParam={updateModelParamValue}
|
||||
/>
|
||||
<ModelParamsSelect
|
||||
title="Model name"
|
||||
modelParamsKey="model"
|
||||
disabled={formDisabled}
|
||||
value={modelParams.model.value}
|
||||
options={availableModels}
|
||||
updateModelParam={updateModelParamValue}
|
||||
/>
|
||||
<ModelParamsSlider
|
||||
title="Temperature"
|
||||
modelParamsKey="temperature"
|
||||
formDisabled={formDisabled}
|
||||
enabled={modelParams.temperature.enabled}
|
||||
setModelParamEnabled={setModelParamEnabled}
|
||||
value={modelParams.temperature.value}
|
||||
min={0}
|
||||
max={modelParams.maxTemperature.value}
|
||||
step={0.01}
|
||||
tooltip="The sampling temperature. Higher values will make the output more random, while lower values will make it more focused and deterministic."
|
||||
updateModelParam={updateModelParamValue}
|
||||
/>
|
||||
<ModelParamsSlider
|
||||
title="Output token limit"
|
||||
modelParamsKey="max_tokens"
|
||||
formDisabled={formDisabled}
|
||||
enabled={modelParams.max_tokens.enabled}
|
||||
setModelParamEnabled={setModelParamEnabled}
|
||||
value={modelParams.max_tokens.value}
|
||||
min={1}
|
||||
max={4096}
|
||||
step={1}
|
||||
tooltip="The maximum number of tokens that can be generated in the chat completion."
|
||||
updateModelParam={updateModelParamValue}
|
||||
/>
|
||||
<ModelParamsSlider
|
||||
title="Top P"
|
||||
modelParamsKey="top_p"
|
||||
formDisabled={formDisabled}
|
||||
enabled={modelParams.top_p.enabled}
|
||||
setModelParamEnabled={setModelParamEnabled}
|
||||
value={modelParams.top_p.value}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
tooltip="An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered. We generally recommend altering this or temperature but not both."
|
||||
updateModelParam={updateModelParamValue}
|
||||
/>
|
||||
<LLMApiKeyComponent {...{ projectId, modelParams }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -148,7 +146,7 @@ const ModelParamsSelect = ({
|
||||
onValueChange={(value) =>
|
||||
updateModelParam(
|
||||
modelParamsKey,
|
||||
value as (typeof supportedModels)[ModelProvider][number],
|
||||
value as (typeof supportedModels)[LLMAdapter][number],
|
||||
)
|
||||
}
|
||||
value={value}
|
||||
|
||||
@@ -67,7 +67,7 @@ const supportChannels = [
|
||||
title: "Slack Connect",
|
||||
description: "Get a dedicated support channel for you and your team.",
|
||||
href: "mailto:help@langfuse.com?subject=Slack%20Connect%20Request&body=I'd%20like%20to%20request%20a%20dedicated%20Slack%20Connect%20channel%20for%20me%20and%20my%20team.%0D%0A%0D%0AUsers%20(emails)%20to%20include%20besides%20mine%3A%0D%0A%0D%0A",
|
||||
buttonText: "Request Slack Connect (via Email)",
|
||||
buttonText: "Request via Email",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -53,16 +53,13 @@ export function DeleteButton({
|
||||
},
|
||||
});
|
||||
|
||||
if (!hasAccess) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover key={itemId}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant={isTableAction ? "ghost" : "outline"}
|
||||
size={isTableAction ? "xs" : "icon"}
|
||||
disabled={!hasAccess}
|
||||
onClick={() =>
|
||||
type === "trace"
|
||||
? capture("trace:delete_form_open", {
|
||||
|
||||
@@ -32,6 +32,7 @@ import useLocalStorage from "@/src/components/useLocalStorage";
|
||||
import { ProjectNavigation } from "@/src/components/projectNavigation";
|
||||
import DOMPurify from "dompurify";
|
||||
import { ThemeToggle } from "@/src/features/theming/ThemeToggle";
|
||||
import { useIsEeEnabled } from "@/src/ee/utils/useIsEeEnabled";
|
||||
|
||||
const signOutUser = async () => {
|
||||
localStorage.clear();
|
||||
@@ -76,6 +77,7 @@ export default function Layout(props: PropsWithChildren) {
|
||||
session.data?.environment.enableExperimentalFeatures ?? false;
|
||||
|
||||
const projectId = router.query.projectId as string | undefined;
|
||||
const isEeEnabled = useIsEeEnabled();
|
||||
|
||||
const mapNavigation = (route: Route): NavigationItem | null => {
|
||||
// Project-level routes
|
||||
@@ -91,11 +93,14 @@ export default function Layout(props: PropsWithChildren) {
|
||||
)
|
||||
return null;
|
||||
|
||||
// cloud only
|
||||
// check ee or cloud requirements
|
||||
if (
|
||||
route.cloudOnly !== undefined &&
|
||||
// the feature should be available in local development
|
||||
route.cloudOnly !== (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined)
|
||||
route.requires !== undefined &&
|
||||
!(
|
||||
(route.requires === "cloud" &&
|
||||
Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION)) ||
|
||||
(route.requires === "cloud-or-ee" && isEeEnabled)
|
||||
)
|
||||
)
|
||||
return null;
|
||||
|
||||
@@ -147,6 +152,7 @@ export default function Layout(props: PropsWithChildren) {
|
||||
!publishablePaths.includes(router.pathname) &&
|
||||
!router.pathname.startsWith("/public/")
|
||||
) {
|
||||
console.warn("Layout: User was signed out as db user was not found");
|
||||
signOutUser();
|
||||
|
||||
return <Spinner message="Redirecting" />;
|
||||
@@ -521,9 +527,9 @@ const MainNavigation: React.FC<{
|
||||
onNavitemClick?: () => void;
|
||||
className?: string;
|
||||
}> = ({ nav, onNavitemClick, className }) => {
|
||||
const [isOpen, setIsOpen] = useLocalStorage(
|
||||
"sidebar-tracing-default-open",
|
||||
false,
|
||||
const [isOpen, setIsOpen] = useLocalStorage<Record<string, boolean>>(
|
||||
"sidebar-item-default-open",
|
||||
{},
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -572,14 +578,20 @@ const MainNavigation: React.FC<{
|
||||
<Disclosure
|
||||
as="div"
|
||||
defaultOpen={
|
||||
item.children.some((child) => child.current) || isOpen
|
||||
item.children.some((child) => child.current) ||
|
||||
isOpen[item.name]
|
||||
}
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Disclosure.Button
|
||||
className="group flex w-full items-center gap-x-3 rounded-md p-2 text-left text-sm font-semibold hover:bg-primary-foreground hover:text-primary-accent"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
onClick={() =>
|
||||
setIsOpen((prev) => ({
|
||||
...prev,
|
||||
[item.name]: !prev[item.name],
|
||||
}))
|
||||
}
|
||||
>
|
||||
{item.icon && (
|
||||
<item.icon
|
||||
|
||||
@@ -24,7 +24,7 @@ export type Route = {
|
||||
children?: Array<Route>; // folder
|
||||
bottom?: boolean; // bottom of the sidebar, only for first level routes
|
||||
newTab?: boolean; // open in new tab
|
||||
cloudOnly?: boolean; // only available in cloud
|
||||
requires?: "cloud" | "cloud-or-ee"; // feature requires cloud or ee
|
||||
};
|
||||
|
||||
export const ROUTES: Route[] = [
|
||||
@@ -62,25 +62,25 @@ export const ROUTES: Route[] = [
|
||||
{
|
||||
name: "Evaluation",
|
||||
icon: Lightbulb,
|
||||
cloudOnly: true,
|
||||
requires: "cloud",
|
||||
label: "Beta",
|
||||
children: [
|
||||
{
|
||||
name: "Templates",
|
||||
pathname: `/project/[projectId]/evals/templates`,
|
||||
cloudOnly: true,
|
||||
requires: "cloud",
|
||||
rbacScope: "evalTemplate:read",
|
||||
},
|
||||
{
|
||||
name: "Configs",
|
||||
pathname: `/project/[projectId]/evals/configs`,
|
||||
cloudOnly: true,
|
||||
requires: "cloud",
|
||||
rbacScope: "evalJob:read",
|
||||
},
|
||||
{
|
||||
name: "Log",
|
||||
pathname: `/project/[projectId]/evals/log`,
|
||||
cloudOnly: true,
|
||||
requires: "cloud",
|
||||
rbacScope: "evalJobExecution:read",
|
||||
},
|
||||
],
|
||||
@@ -100,7 +100,7 @@ export const ROUTES: Route[] = [
|
||||
name: "Playground",
|
||||
pathname: "/project/[projectId]/playground",
|
||||
icon: TerminalIcon,
|
||||
cloudOnly: true,
|
||||
requires: "cloud-or-ee",
|
||||
label: "Beta",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -12,33 +12,19 @@ import { useDetailPageLists } from "@/src/features/navigate-detail-pages/context
|
||||
import { api } from "@/src/utils/api";
|
||||
import { usdFormatter } from "@/src/utils/numbers";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useWindowVirtualizer } from "@tanstack/react-virtual";
|
||||
import { type RouterOutput } from "@/src/utils/types";
|
||||
import {
|
||||
DataTableRowHeightSwitch,
|
||||
type RowHeight,
|
||||
useRowHeightLocalStorage,
|
||||
} from "@/src/components/table/data-table-row-height-switch";
|
||||
import { ScrollArea } from "@/src/components/ui/scroll-area";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AnnotateButton } from "@/src/features/manual-scoring/components/AnnotateButton";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
|
||||
// do not use the usual table row heights here
|
||||
const rowHeightMapping: Record<RowHeight, number> = {
|
||||
s: 200,
|
||||
m: 350,
|
||||
l: 700,
|
||||
};
|
||||
// some projects have thousands of traces in a sessions, paginate to avoid rendering all at once
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
export const SessionPage: React.FC<{
|
||||
sessionId: string;
|
||||
projectId: string;
|
||||
}> = ({ sessionId, projectId }) => {
|
||||
const { setDetailPageList } = useDetailPageLists();
|
||||
const [rowHeight, setRowHeight] = useRowHeightLocalStorage(
|
||||
"single-session",
|
||||
"m",
|
||||
);
|
||||
const [visibleTraces, setVisibleTraces] = useState(PAGE_SIZE);
|
||||
const session = api.sessions.byId.useQuery(
|
||||
{
|
||||
sessionId,
|
||||
@@ -96,11 +82,6 @@ export const SessionPage: React.FC<{
|
||||
}
|
||||
listKey="sessions"
|
||||
/>,
|
||||
<DataTableRowHeightSwitch
|
||||
rowHeight={rowHeight}
|
||||
setRowHeight={setRowHeight}
|
||||
key="height"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -121,97 +102,49 @@ export const SessionPage: React.FC<{
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{session.data && (
|
||||
<TraceCardList
|
||||
session={session.data}
|
||||
projectId={projectId}
|
||||
rowHeight={rowHeightMapping[rowHeight]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TraceCardList = ({
|
||||
session,
|
||||
projectId,
|
||||
rowHeight,
|
||||
}: {
|
||||
session: RouterOutput["sessions"]["byId"];
|
||||
projectId: string;
|
||||
rowHeight: number;
|
||||
}) => {
|
||||
const listVirtualizationRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const virtualizer = useWindowVirtualizer({
|
||||
count: session.traces.length,
|
||||
estimateSize: () => rowHeight,
|
||||
overscan: 5,
|
||||
scrollMargin: listVirtualizationRef.current?.offsetTop ?? 0,
|
||||
gap: 10,
|
||||
});
|
||||
useEffect(() => {
|
||||
// re-measure when rowHeight changes to update the virtualizer
|
||||
virtualizer.measure();
|
||||
}, [rowHeight, virtualizer]);
|
||||
return (
|
||||
<div className="mt-5 border-t pt-5">
|
||||
<div
|
||||
ref={listVirtualizationRef}
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{virtualizer
|
||||
.getVirtualItems()
|
||||
.map((virtualItem) => ({
|
||||
virtualItem,
|
||||
trace: session.traces[virtualItem.index],
|
||||
}))
|
||||
.map(({ virtualItem, trace }) => (
|
||||
<Card
|
||||
className="group grid w-full gap-3 overflow-hidden border-border p-2 shadow-none hover:border-ring md:grid-cols-3"
|
||||
key={virtualItem.key}
|
||||
data-index={virtualItem.index}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
height: `${rowHeight}px`,
|
||||
transform: `translateY(${virtualItem.start - virtualizer.options.scrollMargin}px)`,
|
||||
}}
|
||||
>
|
||||
<ScrollArea className="col-span-2 pr-3">
|
||||
<SessionIO traceId={trace.id} />
|
||||
</ScrollArea>
|
||||
<ScrollArea>
|
||||
<div className="-mt-1 overflow-y-auto p-1 opacity-50 transition-opacity group-hover:opacity-100">
|
||||
<Link
|
||||
href={`/project/${projectId}/traces/${trace.id}`}
|
||||
className="text-xs hover:underline"
|
||||
>
|
||||
Trace: {trace.name} ({trace.id}) ↗
|
||||
</Link>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{trace.timestamp.toLocaleString()}
|
||||
</div>
|
||||
<div className="mb-1 mt-2 text-xs text-muted-foreground">
|
||||
Scores
|
||||
</div>
|
||||
<div className="flex flex-wrap content-start items-start gap-1">
|
||||
<GroupedScoreBadges scores={trace.scores} />
|
||||
</div>
|
||||
<AnnotateButton
|
||||
projectId={projectId}
|
||||
traceId={trace.id}
|
||||
scores={trace.scores}
|
||||
variant="badge"
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Card>
|
||||
))}
|
||||
<div className="mt-5 flex flex-col gap-2 border-t pt-5">
|
||||
{session.data?.traces.slice(0, visibleTraces).map((trace) => (
|
||||
<Card
|
||||
className="group grid gap-3 border-border p-2 shadow-none hover:border-ring md:grid-cols-3"
|
||||
key={trace.id}
|
||||
>
|
||||
<SessionIO traceId={trace.id} />
|
||||
<div className="-mt-1 p-1 opacity-50 transition-opacity group-hover:opacity-100">
|
||||
<Link
|
||||
href={`/project/${projectId}/traces/${trace.id}`}
|
||||
className="text-xs hover:underline"
|
||||
>
|
||||
Trace: {trace.name} ({trace.id}) ↗
|
||||
</Link>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{trace.timestamp.toLocaleString()}
|
||||
</div>
|
||||
<div className="mb-1 mt-2 text-xs text-muted-foreground">
|
||||
Scores
|
||||
</div>
|
||||
<div className="mb-1 flex flex-wrap content-start items-start gap-1">
|
||||
<GroupedScoreBadges scores={trace.scores} />
|
||||
</div>
|
||||
<AnnotateButton
|
||||
projectId={projectId}
|
||||
traceId={trace.id}
|
||||
scores={trace.scores}
|
||||
variant="badge"
|
||||
type="session"
|
||||
source="SessionDetail"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
{session.data?.traces && session.data.traces.length > visibleTraces && (
|
||||
<Button
|
||||
onClick={() => setVisibleTraces((prev) => prev + PAGE_SIZE)}
|
||||
variant="ghost"
|
||||
className="self-center"
|
||||
>
|
||||
{`Load ${Math.min(session.data.traces.length - visibleTraces, PAGE_SIZE)} More`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -230,9 +163,8 @@ const SessionIO = ({ traceId }: { traceId: string }) => {
|
||||
refetchOnMount: false, // prevents refetching loops
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 overflow-x-hidden overflow-y-scroll p-0">
|
||||
<div className="col-span-2 flex flex-col gap-2 p-0">
|
||||
{!trace.data ? (
|
||||
<JsonSkeleton
|
||||
className="h-full w-full overflow-hidden px-2 py-1"
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { DataTable } from "@/src/components/table/data-table";
|
||||
import { type LangfuseColumnDef } from "@/src/components/table/types";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/src/components/ui/popover";
|
||||
import { useState } from "react";
|
||||
import useColumnVisibility from "@/src/features/column-visibility/hooks/useColumnVisibility";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { useHasAccess } from "@/src/features/rbac/utils/checkAccess";
|
||||
@@ -10,6 +16,7 @@ import { type Prisma, type Model } from "@langfuse/shared/src/db";
|
||||
import Decimal from "decimal.js";
|
||||
import { Trash } from "lucide-react";
|
||||
import { useQueryParams, withDefault, NumberParam } from "use-query-params";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
|
||||
export type ModelTableRow = {
|
||||
modelId: string;
|
||||
@@ -222,13 +229,12 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
accessorKey: "actions",
|
||||
header: "Actions",
|
||||
cell: ({ row }) => {
|
||||
return row.original.maintainer === "User" ? (
|
||||
return (
|
||||
<DeleteModelButton
|
||||
projectId={projectId}
|
||||
modelId={row.original.modelId}
|
||||
isBuiltIn={row.original.maintainer === "Langfuse"}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-6" />
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -287,10 +293,13 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
const DeleteModelButton = ({
|
||||
modelId,
|
||||
projectId,
|
||||
isBuiltIn,
|
||||
}: {
|
||||
modelId: string;
|
||||
projectId: string;
|
||||
isBuiltIn?: boolean;
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
const capture = usePostHogClientCapture();
|
||||
const mut = api.models.delete.useMutation({
|
||||
@@ -304,32 +313,48 @@ const DeleteModelButton = ({
|
||||
scope: "models:CUD",
|
||||
});
|
||||
|
||||
if (!hasAccess) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
const confirmDelete = window.confirm(
|
||||
"Are you sure you want to delete this model?",
|
||||
);
|
||||
if (confirmDelete) {
|
||||
capture("models:delete_button_click");
|
||||
mut
|
||||
.mutateAsync({
|
||||
projectId,
|
||||
modelId,
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash size={14} />
|
||||
</Button>
|
||||
<Popover open={isOpen} onOpenChange={() => setIsOpen(!isOpen)}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
disabled={!hasAccess || isBuiltIn}
|
||||
title={
|
||||
isBuiltIn ? "Built-in models cannot be deleted" : "Delete model"
|
||||
}
|
||||
className={cn(
|
||||
isBuiltIn &&
|
||||
"disabled:pointer-events-auto disabled:cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<h2 className="text-md mb-3 font-semibold">Please confirm</h2>
|
||||
<p className="mb-3 text-sm">
|
||||
This action permanently deletes this model definition.
|
||||
</p>
|
||||
<div className="flex justify-end space-x-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
loading={mut.isLoading}
|
||||
onClick={() => {
|
||||
capture("models:delete_button_click");
|
||||
mut.mutateAsync({
|
||||
projectId,
|
||||
modelId,
|
||||
});
|
||||
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Delete Model
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
export function joinTableCoreAndMetrics<
|
||||
Core extends { id: string },
|
||||
Metric extends { id: string },
|
||||
>(
|
||||
userCoreData?: Core[],
|
||||
userMetricsData?: Metric[],
|
||||
): {
|
||||
status: "loading" | "error" | "success";
|
||||
rows: (Core & Partial<Metric>)[] | undefined;
|
||||
} {
|
||||
if (!userCoreData) {
|
||||
return { status: "error", rows: undefined };
|
||||
}
|
||||
|
||||
const userCoreDataProcessed = userCoreData;
|
||||
|
||||
if (!userMetricsData) {
|
||||
// create an object with all the keys of the UserMetrics type with undefined value
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
rows: userCoreDataProcessed.map((u) => ({
|
||||
...u,
|
||||
...({} as Partial<Metric>),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const metricsById = userMetricsData.reduce<Record<string, Metric>>(
|
||||
(acc, metric) => {
|
||||
acc[metric.id] = metric;
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const joinedData = userCoreDataProcessed.map((userCore) => {
|
||||
const metrics = metricsById[userCore.id];
|
||||
return {
|
||||
...userCore,
|
||||
...(metrics ?? ({} as Partial<Metric>)),
|
||||
};
|
||||
});
|
||||
|
||||
return { status: "success", rows: joinedData };
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { JSONView } from "@/src/components/ui/CodeJsonViewer";
|
||||
import { z } from "zod";
|
||||
import { deepParseJson } from "@/src/utils/json";
|
||||
import { deepParseJson } from "@langfuse/shared";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
|
||||
@@ -159,6 +159,7 @@ export const ObservationPreview = (props: {
|
||||
traceId={preloadedObservation.traceId}
|
||||
observationId={preloadedObservation.id}
|
||||
scores={props.scores}
|
||||
type="observation"
|
||||
/>
|
||||
{observationWithInputAndOutput.data?.type === "GENERATION" && (
|
||||
<JumpToPlaygroundButton
|
||||
@@ -237,7 +238,7 @@ const PromptBadge = (props: { promptId: string; projectId: string }) => {
|
||||
if (prompt.isLoading || !prompt.data) return null;
|
||||
return (
|
||||
<Link
|
||||
href={`/project/${props.projectId}/prompts/${prompt.data.name}?version=${prompt.data.version}`}
|
||||
href={`/project/${props.projectId}/prompts/${encodeURIComponent(prompt.data.name)}?version=${prompt.data.version}`}
|
||||
>
|
||||
<Badge>
|
||||
Prompt: {prompt.data.name}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type NestedObservation } from "@/src/utils/types";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { type Trace, type Score, $Enums } from "@langfuse/shared";
|
||||
import { type Trace, type Score, type $Enums } from "@langfuse/shared";
|
||||
import { GroupedScoreBadges } from "@/src/components/grouped-score-badge";
|
||||
import { Fragment } from "react";
|
||||
import { type ObservationReturnType } from "@/src/server/api/routers/traces";
|
||||
@@ -10,6 +10,10 @@ import { MinusCircle, MinusIcon, PlusCircleIcon, PlusIcon } from "lucide-react";
|
||||
import { Toggle } from "@/src/components/ui/toggle";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import {
|
||||
nestObservations,
|
||||
treeItemColors,
|
||||
} from "@/src/components/trace/lib/helpers";
|
||||
|
||||
export const ObservationTree = (props: {
|
||||
observations: ObservationReturnType[];
|
||||
@@ -257,53 +261,14 @@ const ObservationTreeNode = (props: {
|
||||
const ColorCodedObservationType = (props: {
|
||||
observationType: $Enums.ObservationType;
|
||||
}) => {
|
||||
const colors: Record<$Enums.ObservationType, string> = {
|
||||
[$Enums.ObservationType.SPAN]: "bg-muted-blue",
|
||||
[$Enums.ObservationType.GENERATION]: "bg-muted-orange",
|
||||
[$Enums.ObservationType.EVENT]: "bg-muted-green",
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"self-start rounded-sm p-1 text-xs",
|
||||
colors[props.observationType],
|
||||
treeItemColors.get(props.observationType),
|
||||
)}
|
||||
>
|
||||
{props.observationType}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export function nestObservations(
|
||||
list: ObservationReturnType[],
|
||||
): NestedObservation[] {
|
||||
if (list.length === 0) return [];
|
||||
|
||||
// Step 1: Create a map where the keys are object IDs, and the values are
|
||||
// the corresponding objects with an added 'children' property.
|
||||
const map = new Map<string, NestedObservation>();
|
||||
for (const obj of list) {
|
||||
map.set(obj.id, { ...obj, children: [] });
|
||||
}
|
||||
|
||||
// Step 2: Create another map for the roots of all trees.
|
||||
const roots = new Map<string, NestedObservation>();
|
||||
|
||||
// Step 3: Populate the 'children' arrays and root map.
|
||||
for (const obj of map.values()) {
|
||||
if (obj.parentObservationId) {
|
||||
const parent = map.get(obj.parentObservationId);
|
||||
if (parent) {
|
||||
parent.children.push(obj);
|
||||
}
|
||||
} else {
|
||||
roots.set(obj.id, obj);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO sum token amounts per level
|
||||
|
||||
// Step 4: Return the roots.
|
||||
return Array.from(roots.values());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
import { Card } from "@/src/components/ui/card";
|
||||
import { type ObservationReturnType } from "@/src/server/api/routers/traces";
|
||||
import { type Score, type Trace } from "@langfuse/shared";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { SimpleTreeView } from "@mui/x-tree-view/SimpleTreeView";
|
||||
import { TreeItem } from "@mui/x-tree-view/TreeItem";
|
||||
|
||||
import { MinusIcon, PlusIcon, PanelRightOpen } from "lucide-react";
|
||||
import { nestObservations } from "@/src/components/trace/lib/helpers";
|
||||
import { type NestedObservation } from "@/src/utils/types";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import {
|
||||
type TreeItemType,
|
||||
treeItemColors,
|
||||
} from "@/src/components/trace/lib/helpers";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerTrigger,
|
||||
} from "@/src/components/ui/drawer";
|
||||
import { TracePreview } from "@/src/components/trace/TracePreview";
|
||||
import { ObservationPreview } from "@/src/components/trace/ObservationPreview";
|
||||
import useSessionStorage from "@/src/components/useSessionStorage";
|
||||
|
||||
// Fixed widths for styling for v1
|
||||
const SCALE_WIDTH = 800;
|
||||
const STEP_SIZE = 100;
|
||||
const CARD_PADDING = 60;
|
||||
const LABEL_WIDTH = 35;
|
||||
const MIN_LABEL_WIDTH = 250;
|
||||
const TREE_INDENTATION = 12; // default in MUI X TreeView
|
||||
|
||||
const PREDEFINED_STEP_SIZES = [
|
||||
0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 2.5, 3, 4, 5, 6, 7, 8, 9, 10,
|
||||
];
|
||||
|
||||
const calculateStepSize = (latency: number, scaleWidth: number) => {
|
||||
const calculatedStepSize = latency / (scaleWidth / STEP_SIZE);
|
||||
return (
|
||||
PREDEFINED_STEP_SIZES.find((step) => step >= calculatedStepSize) ||
|
||||
PREDEFINED_STEP_SIZES[PREDEFINED_STEP_SIZES.length - 1]
|
||||
);
|
||||
};
|
||||
|
||||
function TreeItemInner({
|
||||
latency,
|
||||
totalScaleSpan,
|
||||
type,
|
||||
startOffset = 0,
|
||||
name,
|
||||
children,
|
||||
setBackgroundColor,
|
||||
level = 0,
|
||||
cardWidth,
|
||||
}: {
|
||||
latency: number;
|
||||
totalScaleSpan: number;
|
||||
type: TreeItemType;
|
||||
startOffset?: number;
|
||||
name?: string | null;
|
||||
children?: React.ReactNode;
|
||||
setBackgroundColor: (color: string) => void;
|
||||
level?: number;
|
||||
cardWidth: number;
|
||||
}) {
|
||||
const itemWidth = (latency / totalScaleSpan) * SCALE_WIDTH;
|
||||
const itemOffsetLabelWidth = itemWidth + startOffset + LABEL_WIDTH;
|
||||
const customLabelWidth = cardWidth - SCALE_WIDTH - CARD_PADDING;
|
||||
|
||||
return (
|
||||
<div className="group my-1 grid w-full min-w-fit grid-cols-[1fr,auto] items-center">
|
||||
<div
|
||||
className="flex flex-row items-center gap-2"
|
||||
style={{
|
||||
maxWidth: customLabelWidth - level * TREE_INDENTATION,
|
||||
minWidth: MIN_LABEL_WIDTH - LABEL_WIDTH - level * TREE_INDENTATION,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cn("rounded-sm p-1 text-xs", treeItemColors.get(type))}
|
||||
>
|
||||
{type}
|
||||
</span>
|
||||
<span
|
||||
className="w-fit-content overflow-hidden text-ellipsis whitespace-nowrap break-all text-sm"
|
||||
title={name ?? undefined}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
<div
|
||||
className="w-6 flex-1"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Drawer
|
||||
onOpenChange={(open) => setBackgroundColor(open ? "!bg-muted" : "")}
|
||||
>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
className="focus:none active:none hidden justify-start hover:!bg-transparent group-hover:block"
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<PanelRightOpen className="h-4 w-4"></PanelRightOpen>
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className="h-1/2 w-full overflow-hidden md:w-3/5 lg:w-3/5 xl:w-3/5 2xl:w-3/5">
|
||||
{children}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center" style={{ width: `${SCALE_WIDTH}px` }}>
|
||||
<div className={`relative w-[${SCALE_WIDTH}px]`}>
|
||||
<div className="ml-4 mr-4 h-full border-r-2"></div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-5 items-center justify-end rounded-sm",
|
||||
itemWidth
|
||||
? treeItemColors.get(type)
|
||||
: "border border-dashed bg-muted",
|
||||
)}
|
||||
style={{
|
||||
width: `${itemWidth || 10}px`,
|
||||
marginLeft: `${startOffset}px`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"hidden justify-end text-xs text-muted-foreground group-hover:block",
|
||||
itemOffsetLabelWidth > SCALE_WIDTH
|
||||
? "mr-1"
|
||||
: !!latency
|
||||
? "-mr-9"
|
||||
: "-mr-6",
|
||||
)}
|
||||
>
|
||||
{!!latency ? `${latency.toFixed(2)}s` : "n/a"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TraceTreeItem({
|
||||
observation,
|
||||
level = 0,
|
||||
traceStartTime,
|
||||
totalScaleSpan,
|
||||
projectId,
|
||||
scores,
|
||||
observations,
|
||||
cardWidth,
|
||||
}: {
|
||||
observation: NestedObservation;
|
||||
level: number;
|
||||
traceStartTime: Date;
|
||||
totalScaleSpan: number;
|
||||
projectId: string;
|
||||
scores: Score[];
|
||||
observations: Array<ObservationReturnType>;
|
||||
cardWidth: number;
|
||||
}) {
|
||||
const { startTime, endTime } = observation || {};
|
||||
const [backgroundColor, setBackgroundColor] = useState("");
|
||||
|
||||
const latency = endTime
|
||||
? (endTime.getTime() - startTime.getTime()) / 1000
|
||||
: 0;
|
||||
const startOffset =
|
||||
((startTime.getTime() - traceStartTime.getTime()) / totalScaleSpan / 1000) *
|
||||
SCALE_WIDTH;
|
||||
|
||||
return (
|
||||
<TreeItem
|
||||
classes={{
|
||||
content: `border-l border-dashed !rounded-none ${backgroundColor} !min-w-fit hover:!bg-muted`,
|
||||
selected: "!bg-background !important hover:!bg-muted",
|
||||
label: "!min-w-fit",
|
||||
}}
|
||||
key={observation.id}
|
||||
itemId={observation.id}
|
||||
label={
|
||||
<TreeItemInner
|
||||
latency={latency}
|
||||
type={observation.type}
|
||||
name={observation.name}
|
||||
startOffset={startOffset}
|
||||
totalScaleSpan={totalScaleSpan}
|
||||
setBackgroundColor={setBackgroundColor}
|
||||
level={level}
|
||||
cardWidth={cardWidth}
|
||||
>
|
||||
<>
|
||||
<h3 className="mb-6 px-8 pt-8 text-2xl font-semibold tracking-tight">
|
||||
Detail view
|
||||
</h3>
|
||||
<div className="overflow-y-auto px-8 pb-8 pt-2">
|
||||
<ObservationPreview
|
||||
observations={observations}
|
||||
scores={scores}
|
||||
projectId={projectId}
|
||||
currentObservationId={observation.id}
|
||||
traceId={observation.traceId}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</TreeItemInner>
|
||||
}
|
||||
>
|
||||
{Array.isArray(observation.children)
|
||||
? observation.children.map((child) => (
|
||||
<TraceTreeItem
|
||||
key={child.id}
|
||||
observation={child}
|
||||
level={level + 1}
|
||||
traceStartTime={traceStartTime}
|
||||
totalScaleSpan={totalScaleSpan}
|
||||
projectId={projectId}
|
||||
scores={scores}
|
||||
observations={observations}
|
||||
cardWidth={cardWidth}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</TreeItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function TraceTimelineView({
|
||||
trace,
|
||||
observations,
|
||||
projectId,
|
||||
scores,
|
||||
}: {
|
||||
trace: Trace & { latency?: number };
|
||||
observations: Array<ObservationReturnType>;
|
||||
projectId: string;
|
||||
scores: Score[];
|
||||
}) {
|
||||
const { latency, name, id } = trace;
|
||||
const [backgroundColor, setBackgroundColor] = useState("");
|
||||
const [expandedItems, setExpandedItems] = useSessionStorage<string[]>(
|
||||
`${trace.id}-expanded`,
|
||||
[trace.id],
|
||||
);
|
||||
|
||||
const [cardWidth, setCardWidth] = useState(0);
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (parentRef.current) {
|
||||
const availableWidth = parentRef.current.offsetWidth;
|
||||
setCardWidth(availableWidth);
|
||||
}
|
||||
};
|
||||
|
||||
handleResize();
|
||||
window.addEventListener("resize", handleResize); // Recalculate on window resize
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, [parentRef]);
|
||||
|
||||
if (!latency) return null;
|
||||
|
||||
const nestedObservations = nestObservations(observations);
|
||||
const stepSize = calculateStepSize(latency, SCALE_WIDTH);
|
||||
const totalScaleSpan = stepSize * (SCALE_WIDTH / STEP_SIZE);
|
||||
|
||||
return (
|
||||
<div ref={parentRef} className="w-full">
|
||||
<Card
|
||||
className="flex max-h-[calc(100dvh-24rem)] flex-col overflow-x-auto overflow-y-hidden"
|
||||
style={{ width: cardWidth }}
|
||||
>
|
||||
<div className="grid w-full grid-cols-[1fr,auto] items-center p-2">
|
||||
<h3
|
||||
className="p-2 text-2xl font-semibold tracking-tight"
|
||||
style={{
|
||||
minWidth: `${MIN_LABEL_WIDTH}px`,
|
||||
}}
|
||||
>
|
||||
Trace Timeline
|
||||
</h3>
|
||||
<div
|
||||
className="relative mr-2 h-4"
|
||||
style={{ width: `${SCALE_WIDTH}px` }}
|
||||
>
|
||||
{Array.from({ length: SCALE_WIDTH / STEP_SIZE + 1 }).map(
|
||||
(_, index) => {
|
||||
const step = stepSize * index;
|
||||
const isLastStep = index === SCALE_WIDTH / STEP_SIZE;
|
||||
|
||||
return isLastStep ? (
|
||||
<span
|
||||
className="absolute -right-2 text-xs text-muted-foreground"
|
||||
key={index}
|
||||
>
|
||||
{step.toFixed(2)}s
|
||||
</span>
|
||||
) : (
|
||||
<div
|
||||
key={index}
|
||||
className="absolute h-full border border-l text-xs"
|
||||
style={{ left: `${index * STEP_SIZE}px` }}
|
||||
>
|
||||
<span className="absolute left-2 text-xs text-muted-foreground">
|
||||
{step.toFixed(2)}s
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-fit overflow-y-auto p-2">
|
||||
<SimpleTreeView
|
||||
slots={{
|
||||
expandIcon: PlusIcon,
|
||||
collapseIcon: MinusIcon,
|
||||
}}
|
||||
expandedItems={expandedItems}
|
||||
onExpandedItemsChange={(_, itemIds) => setExpandedItems(itemIds)}
|
||||
itemChildrenIndentation={TREE_INDENTATION}
|
||||
>
|
||||
<TreeItem
|
||||
key={id}
|
||||
itemId={id}
|
||||
classes={{
|
||||
content: `${backgroundColor} !min-w-fit !hover:bg-muted`,
|
||||
selected: "!bg-background !important hover:!bg-muted",
|
||||
label: "!min-w-fit",
|
||||
}}
|
||||
label={
|
||||
<TreeItemInner
|
||||
name={name}
|
||||
latency={latency}
|
||||
totalScaleSpan={totalScaleSpan}
|
||||
setBackgroundColor={setBackgroundColor}
|
||||
type="TRACE"
|
||||
cardWidth={cardWidth}
|
||||
>
|
||||
<div className="overflow-y-auto p-8">
|
||||
<h3 className="mb-6 text-2xl font-semibold tracking-tight">
|
||||
Detail view
|
||||
</h3>
|
||||
<TracePreview
|
||||
trace={trace}
|
||||
observations={observations}
|
||||
scores={scores}
|
||||
/>
|
||||
</div>
|
||||
</TreeItemInner>
|
||||
}
|
||||
>
|
||||
{Boolean(nestedObservations.length)
|
||||
? nestedObservations.map((observation) => (
|
||||
<TraceTreeItem
|
||||
key={observation.id}
|
||||
observation={observation}
|
||||
level={1}
|
||||
traceStartTime={nestedObservations[0].startTime}
|
||||
totalScaleSpan={totalScaleSpan}
|
||||
projectId={projectId}
|
||||
scores={scores}
|
||||
observations={observations}
|
||||
cardWidth={cardWidth}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</TreeItem>
|
||||
</SimpleTreeView>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { TracePreview } from "./TracePreview";
|
||||
import Header from "@/src/components/layouts/header";
|
||||
import { Badge } from "@/src/components/ui/badge";
|
||||
import { TraceAggUsageBadge } from "@/src/components/token-usage-badge";
|
||||
import { StringParam, useQueryParam } from "use-query-params";
|
||||
import { StringParam, useQueryParam, withDefault } from "use-query-params";
|
||||
import { PublishTraceSwitch } from "@/src/components/publish-object-switch";
|
||||
import { DetailPageNav } from "@/src/features/navigate-detail-pages/DetailPageNav";
|
||||
import { useRouter } from "next/router";
|
||||
@@ -18,12 +18,22 @@ import { ErrorPage } from "@/src/components/error-page";
|
||||
import { TagTraceDetailsPopover } from "@/src/features/tag/components/TagTraceDetailsPopover";
|
||||
import useLocalStorage from "@/src/components/useLocalStorage";
|
||||
import { Toggle } from "@/src/components/ui/toggle";
|
||||
import { Award, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
|
||||
import {
|
||||
Award,
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
ListTree,
|
||||
Network,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { usdFormatter } from "@/src/utils/numbers";
|
||||
import Decimal from "decimal.js";
|
||||
import { useCallback, useState } from "react";
|
||||
import { DeleteButton } from "@/src/components/deleteButton";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/src/components/ui/tabs";
|
||||
import { TraceTimelineView } from "@/src/components/trace/TraceTimelineView";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/src/components/ui/alert";
|
||||
|
||||
export function Trace(props: {
|
||||
observations: Array<ObservationReturnType>;
|
||||
@@ -168,6 +178,7 @@ export function Trace(props: {
|
||||
}
|
||||
|
||||
export function TracePage({ traceId }: { traceId: string }) {
|
||||
const capture = usePostHogClientCapture();
|
||||
const router = useRouter();
|
||||
const utils = api.useUtils();
|
||||
const trace = api.traces.byId.useQuery(
|
||||
@@ -199,6 +210,11 @@ export function TracePage({ traceId }: { traceId: string }) {
|
||||
|
||||
const totalCost = calculateDisplayTotalCost(trace.data?.observations ?? []);
|
||||
|
||||
const [selectedTab, setSelectedTab] = useQueryParam(
|
||||
"display",
|
||||
withDefault(StringParam, "details"),
|
||||
);
|
||||
|
||||
if (trace.error?.data?.code === "UNAUTHORIZED")
|
||||
return <ErrorPage message="You do not have access to this trace." />;
|
||||
if (!trace.data) return <div>loading...</div>;
|
||||
@@ -227,9 +243,17 @@ export function TracePage({ traceId }: { traceId: string }) {
|
||||
/>
|
||||
<DetailPageNav
|
||||
currentId={traceId}
|
||||
path={(id) =>
|
||||
`/project/${router.query.projectId as string}/traces/${id}`
|
||||
}
|
||||
path={(id) => {
|
||||
const { view, display, projectId } = router.query;
|
||||
const queryParams = new URLSearchParams({
|
||||
...(typeof view === "string" ? { view } : {}),
|
||||
...(typeof display === "string" ? { display } : {}),
|
||||
});
|
||||
const queryParamString = Boolean(queryParams.size)
|
||||
? `?${queryParams.toString()}`
|
||||
: "";
|
||||
return `/project/${projectId as string}/traces/${id}${queryParamString}`;
|
||||
}}
|
||||
listKey="traces"
|
||||
/>
|
||||
<DeleteButton
|
||||
@@ -281,15 +305,70 @@ export function TracePage({ traceId }: { traceId: string }) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex-1 overflow-hidden border-t pt-5">
|
||||
<Trace
|
||||
key={trace.data.id}
|
||||
trace={trace.data}
|
||||
scores={trace.data.scores}
|
||||
projectId={trace.data.projectId}
|
||||
observations={trace.data.observations}
|
||||
/>
|
||||
</div>
|
||||
<Tabs
|
||||
value={selectedTab}
|
||||
onValueChange={(tab) => {
|
||||
setSelectedTab(tab);
|
||||
capture("trace_detail:display_mode_switch", { view: tab });
|
||||
}}
|
||||
className="flex w-full justify-end border-b bg-background"
|
||||
>
|
||||
<TabsList className="bg-background py-0">
|
||||
<TabsTrigger
|
||||
value="details"
|
||||
className="h-full rounded-none border-b-4 border-transparent data-[state=active]:border-primary-accent data-[state=active]:shadow-none"
|
||||
>
|
||||
<Network className="mr-1 h-4 w-4"></Network>
|
||||
Tree
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="timeline"
|
||||
className="h-full rounded-none border-b-4 border-transparent data-[state=active]:border-primary-accent data-[state=active]:shadow-none"
|
||||
>
|
||||
<ListTree className="mr-1 h-4 w-4"></ListTree>
|
||||
Timeline
|
||||
<Badge className="pointer-events-none ml-2 px-1.5">Beta</Badge>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
{selectedTab === "details" && (
|
||||
<div className="mt-5 flex-1 overflow-hidden border-t pt-5">
|
||||
<Trace
|
||||
key={trace.data.id}
|
||||
trace={trace.data}
|
||||
scores={trace.data.scores}
|
||||
projectId={trace.data.projectId}
|
||||
observations={trace.data.observations}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{selectedTab === "timeline" && (
|
||||
<div className="mt-5 flex-1 flex-col space-y-5 overflow-hidden">
|
||||
<Alert>
|
||||
<Terminal className="h-4 w-4" />
|
||||
<AlertTitle>New Trace Timeline (beta)</AlertTitle>
|
||||
<AlertDescription>
|
||||
We value your feedback! Share your thoughts on{" "}
|
||||
<a
|
||||
href="https://github.com/orgs/langfuse/discussions/2195"
|
||||
target="_blank"
|
||||
className="underline"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
GitHub discussions
|
||||
</a>
|
||||
.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<TraceTimelineView
|
||||
key={trace.data.id}
|
||||
trace={trace.data}
|
||||
scores={trace.data.scores}
|
||||
observations={trace.data.observations}
|
||||
projectId={trace.data.projectId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { $Enums } from "@langfuse/shared";
|
||||
import { type NestedObservation } from "@/src/utils/types";
|
||||
import { type ObservationReturnType } from "@/src/server/api/routers/traces";
|
||||
|
||||
export type TreeItemType = $Enums.ObservationType | "TRACE";
|
||||
|
||||
export const treeItemColors: Map<TreeItemType, string> = new Map([
|
||||
[$Enums.ObservationType.SPAN, "bg-muted-blue"],
|
||||
[$Enums.ObservationType.GENERATION, "bg-muted-orange"],
|
||||
[$Enums.ObservationType.EVENT, "bg-muted-green"],
|
||||
["TRACE", "bg-input"],
|
||||
]);
|
||||
|
||||
export function nestObservations(
|
||||
list: ObservationReturnType[],
|
||||
): NestedObservation[] {
|
||||
if (list.length === 0) return [];
|
||||
|
||||
// Step 1: Create a map where the keys are object IDs, and the values are
|
||||
// the corresponding objects with an added 'children' property.
|
||||
const map = new Map<string, NestedObservation>();
|
||||
for (const obj of list) {
|
||||
map.set(obj.id, { ...obj, children: [] });
|
||||
}
|
||||
|
||||
// Step 2: Create another map for the roots of all trees.
|
||||
const roots = new Map<string, NestedObservation>();
|
||||
|
||||
// Step 3: Populate the 'children' arrays and root map.
|
||||
for (const obj of map.values()) {
|
||||
if (obj.parentObservationId) {
|
||||
const parent = map.get(obj.parentObservationId);
|
||||
if (parent) {
|
||||
parent.children.push(obj);
|
||||
}
|
||||
} else {
|
||||
roots.set(obj.id, obj);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Sort children by start time for each parent
|
||||
for (const obj of map.values()) {
|
||||
obj.children.sort((a, b) => a.startTime.getTime() - b.startTime.getTime());
|
||||
}
|
||||
|
||||
// TODO sum token amounts per level
|
||||
|
||||
// Step 5: Return the roots.
|
||||
return Array.from(roots.values());
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { Check, ChevronsDownUp, ChevronsUpDown, Copy } from "lucide-react";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { default as React18JsonView } from "react18-json-view";
|
||||
import "react18-json-view/src/dark.css";
|
||||
import { deepParseJson } from "@/src/utils/json";
|
||||
import { deepParseJson } from "@langfuse/shared";
|
||||
import { Skeleton } from "@/src/components/ui/skeleton";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
/**
|
||||
* useSessionStorage is a hook for managing data with the sessionStorage API.
|
||||
*
|
||||
* @param {string} sessionStorageKey - The key under which the value is stored in sessionStorage.
|
||||
* @param {T} initialValue - The initial value of the data to be stored.
|
||||
*
|
||||
* Note: The object T should be able to be stringified, as it will be stored in sessionStorage as a string.
|
||||
*
|
||||
* @return An array with three elements:
|
||||
* value: Current value
|
||||
* setValue: Function to update the value
|
||||
* clearValue: Function to remove value from the session storage.
|
||||
* This function will also reset the value to initial value
|
||||
*
|
||||
* @template T - The type of the data to be stored in sessionStorage. It should be a type that can be stringified.
|
||||
*
|
||||
* @throws Will throw an error if the stringifying the value or accessing session storage fails.
|
||||
*/
|
||||
function useSessionStorage<T>(
|
||||
sessionStorageKey: string,
|
||||
initialValue: T,
|
||||
): [T, React.Dispatch<React.SetStateAction<T>>, () => void] {
|
||||
const [value, setValue] = useState<T>(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return initialValue;
|
||||
}
|
||||
try {
|
||||
const storedValue = sessionStorage.getItem(sessionStorageKey);
|
||||
return storedValue ? (JSON.parse(storedValue) as T) : initialValue;
|
||||
} catch (error) {
|
||||
console.error("Error reading from session storage", error);
|
||||
return initialValue;
|
||||
}
|
||||
});
|
||||
|
||||
const clearValue = () => {
|
||||
try {
|
||||
sessionStorage.removeItem(sessionStorageKey);
|
||||
setValue(initialValue);
|
||||
} catch (error) {
|
||||
console.error("Error clearing session storage", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
sessionStorage.setItem(sessionStorageKey, JSON.stringify(value));
|
||||
} catch (error) {
|
||||
console.error("Error writing to session storage", error);
|
||||
}
|
||||
}, [sessionStorageKey, value]);
|
||||
|
||||
return [value, setValue, clearValue] as const;
|
||||
}
|
||||
|
||||
export default useSessionStorage;
|
||||
@@ -1 +1 @@
|
||||
export const VERSION = "v2.48.0";
|
||||
export const VERSION = "v2.54.0";
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
PopoverTrigger,
|
||||
} from "@/src/components/ui/popover";
|
||||
import { useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { Trash } from "lucide-react";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
@@ -156,7 +156,7 @@ export function DeactivateConfig({
|
||||
disabled={!hasAccess || config?.status !== "ACTIVE"}
|
||||
loading={isLoading}
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
<Trash className="h-5 w-5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { Input } from "@/src/components/ui/input";
|
||||
@@ -18,19 +18,12 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { extractVariables, getIsCharOrUnderscore } from "@/src/utils/string";
|
||||
import router from "next/router";
|
||||
import { type EvalTemplate } from "@prisma/client";
|
||||
import { ModelParameters } from "@/src/components/ModelParameters";
|
||||
import {
|
||||
ModelParameters,
|
||||
type ModelParamsContext,
|
||||
} from "@/src/components/ModelParameters";
|
||||
import {
|
||||
EvalModelNames,
|
||||
OutputSchema,
|
||||
evalLLMModels,
|
||||
type UIModelParams,
|
||||
ModelProvider,
|
||||
type OpenAIModel,
|
||||
type OpenAIModelParams,
|
||||
type ModelParams,
|
||||
ZodModelConfig,
|
||||
} from "@langfuse/shared";
|
||||
import { PromptDescription } from "@/src/features/prompts/components/prompt-description";
|
||||
import {
|
||||
@@ -43,6 +36,7 @@ import {
|
||||
import { TEMPLATES } from "@/src/ee/features/evals/components/templates";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { getFinalModelParams } from "@/src/ee/utils/getFinalModelParams";
|
||||
import { useModelParams } from "@/src/ee/features/playground/page/hooks/useModelParams";
|
||||
|
||||
export const EvalTemplateForm = (props: {
|
||||
projectId: string;
|
||||
@@ -102,15 +96,6 @@ export const EvalTemplateForm = (props: {
|
||||
score: currentTemplate?.outputScore?.trim() ?? "",
|
||||
reasoning: currentTemplate?.outputReasoning?.trim() ?? "",
|
||||
},
|
||||
model: "gpt-3.5-turbo",
|
||||
modelParams: {
|
||||
model: "gpt-3.5-turbo",
|
||||
provider: ModelProvider.OpenAI,
|
||||
temperature: 1,
|
||||
maxTemperature: 2,
|
||||
max_tokens: 256,
|
||||
top_p: 1,
|
||||
},
|
||||
}
|
||||
: props.existingEvalTemplate
|
||||
? {
|
||||
@@ -121,10 +106,13 @@ export const EvalTemplateForm = (props: {
|
||||
score: string;
|
||||
reasoning: string;
|
||||
},
|
||||
model: props.existingEvalTemplate.model as OpenAIModel,
|
||||
modelParams: props.existingEvalTemplate
|
||||
.modelParams as OpenAIModelParams & {
|
||||
maxTemperature: number;
|
||||
selectedModel: {
|
||||
provider: props.existingEvalTemplate.provider,
|
||||
model: props.existingEvalTemplate.model,
|
||||
modelParams: props.existingEvalTemplate
|
||||
.modelParams as ModelParams & {
|
||||
maxTemperature: number;
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
@@ -135,6 +123,12 @@ export const EvalTemplateForm = (props: {
|
||||
);
|
||||
};
|
||||
|
||||
const selectedModelSchema = z.object({
|
||||
provider: z.string().min(1, "Select a provider"),
|
||||
model: z.string().min(1, "Select a model"),
|
||||
modelParams: ZodModelConfig,
|
||||
});
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, "Enter a name"),
|
||||
prompt: z
|
||||
@@ -167,9 +161,12 @@ export type EvalTemplateFormPreFill = {
|
||||
score: string;
|
||||
reasoning: string;
|
||||
};
|
||||
model: OpenAIModel;
|
||||
modelParams: OpenAIModelParams & {
|
||||
maxTemperature: number;
|
||||
selectedModel?: {
|
||||
provider: string;
|
||||
model: string;
|
||||
modelParams: ModelParams & {
|
||||
maxTemperature: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -186,27 +183,41 @@ export const InnerEvalTemplateForm = (props: {
|
||||
}) => {
|
||||
const capture = usePostHogClientCapture();
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const preFilledModel = useMemo(
|
||||
() => getModelParamsWithEnabledFlag(props.preFilledFormValues),
|
||||
[props.preFilledFormValues],
|
||||
);
|
||||
|
||||
// updates the model params based on the pre-filled data
|
||||
// either form update or from langfuse-generated template
|
||||
const [modelParams, setModelParams] = useState<UIModelParams>(preFilledModel);
|
||||
const updateModelParamValue: ModelParamsContext["updateModelParamValue"] = (
|
||||
key,
|
||||
value,
|
||||
) => {
|
||||
setModelParams((prev) => ({ ...prev, [key]: { ...prev[key], value } }));
|
||||
};
|
||||
const {
|
||||
modelParams,
|
||||
setModelParams,
|
||||
updateModelParamValue,
|
||||
setModelParamEnabled,
|
||||
availableModels,
|
||||
availableProviders,
|
||||
} = useModelParams({ evalModelsOnly: true });
|
||||
|
||||
const setModelParamEnabled: ModelParamsContext["setModelParamEnabled"] = (
|
||||
key,
|
||||
enabled,
|
||||
) => {
|
||||
setModelParams((prev) => ({ ...prev, [key]: { ...prev[key], enabled } }));
|
||||
};
|
||||
useEffect(() => {
|
||||
if (props.preFilledFormValues?.selectedModel) {
|
||||
const { provider, model, modelParams } =
|
||||
props.preFilledFormValues.selectedModel;
|
||||
|
||||
const modelConfig = Object.entries(modelParams).reduce(
|
||||
(acc, [key, value]) => {
|
||||
return {
|
||||
...acc,
|
||||
[key]: { value, enabled: true },
|
||||
};
|
||||
},
|
||||
{} as UIModelParams,
|
||||
);
|
||||
|
||||
setModelParams((prev) => ({
|
||||
...prev,
|
||||
...modelConfig,
|
||||
provider: { value: provider, enabled: true },
|
||||
model: { value: model, enabled: true },
|
||||
}));
|
||||
}
|
||||
}, [props.preFilledFormValues?.selectedModel, setModelParams]);
|
||||
|
||||
// updates the form based on the pre-filled data
|
||||
// either form update or from langfuse-generated template
|
||||
@@ -243,33 +254,8 @@ export const InnerEvalTemplateForm = (props: {
|
||||
outputScore: OutputSchema.parse(props.preFilledFormValues.outputSchema)
|
||||
.score,
|
||||
});
|
||||
|
||||
// state for the model params is outside of the form, hence needs to be handled individually
|
||||
// also set the context for the playground
|
||||
const model = EvalModelNames.parse(preFilledModel.model.value);
|
||||
updateModelParamValue("model", model);
|
||||
setModelParams((prev) => ({
|
||||
...prev,
|
||||
...preFilledModel,
|
||||
}));
|
||||
|
||||
const modelProvider = evalLLMModels.find((m) => m.model.value === model)
|
||||
?.provider.value;
|
||||
|
||||
if (modelProvider) {
|
||||
updateModelParamValue("provider", modelProvider); // updating the provider based on the model
|
||||
updateModelParamValue(
|
||||
"maxTemperature",
|
||||
modelProvider === ModelProvider.OpenAI ? 2 : 1,
|
||||
); // setting the max value of the slider based on the provider
|
||||
}
|
||||
}
|
||||
}, [
|
||||
props.preFilledFormValues,
|
||||
preFilledModel,
|
||||
form,
|
||||
props.existingEvalTemplateName,
|
||||
]);
|
||||
}, [props.preFilledFormValues, form, props.existingEvalTemplateName]);
|
||||
|
||||
const extractedVariables = form.watch("prompt")
|
||||
? extractVariables(form.watch("prompt")).filter(getIsCharOrUnderscore)
|
||||
@@ -288,26 +274,31 @@ export const InnerEvalTemplateForm = (props: {
|
||||
: "eval_templates:new_form_submit",
|
||||
);
|
||||
|
||||
const model = EvalModelNames.safeParse(modelParams.model.value);
|
||||
const evalTemplate = {
|
||||
name: values.name,
|
||||
projectId: props.projectId,
|
||||
prompt: values.prompt,
|
||||
provider: modelParams.provider.value,
|
||||
model: modelParams.model.value,
|
||||
modelParams: getFinalModelParams(modelParams),
|
||||
vars: extractedVariables ?? [],
|
||||
outputSchema: {
|
||||
score: values.outputScore,
|
||||
reasoning: values.outputReasoning,
|
||||
},
|
||||
};
|
||||
|
||||
if (!model.success) {
|
||||
setFormError("Please select a model.");
|
||||
const parsedModel = selectedModelSchema.safeParse(evalTemplate);
|
||||
|
||||
if (!parsedModel.success) {
|
||||
setFormError(
|
||||
`${parsedModel.error.errors[0].path}: ${parsedModel.error.errors[0].message}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
createEvalTemplateMutation
|
||||
.mutateAsync({
|
||||
name: values.name,
|
||||
projectId: props.projectId,
|
||||
prompt: values.prompt,
|
||||
model: model.data,
|
||||
modelParams: getFinalModelParams(modelParams),
|
||||
vars: extractedVariables ?? [],
|
||||
outputSchema: {
|
||||
score: values.outputScore,
|
||||
reasoning: values.outputReasoning,
|
||||
},
|
||||
})
|
||||
.mutateAsync(evalTemplate)
|
||||
.then((res) => {
|
||||
props.onFormSuccess?.();
|
||||
form.reset();
|
||||
@@ -429,10 +420,11 @@ export const InnerEvalTemplateForm = (props: {
|
||||
<ModelParameters
|
||||
{...{
|
||||
modelParams,
|
||||
availableModels,
|
||||
availableProviders,
|
||||
updateModelParamValue: updateModelParamValue,
|
||||
setModelParamEnabled,
|
||||
}}
|
||||
availableModels={[...evalLLMModels]}
|
||||
formDisabled={!props.isEditing}
|
||||
/>
|
||||
</div>
|
||||
@@ -456,33 +448,3 @@ export const InnerEvalTemplateForm = (props: {
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
function getModelParamsWithEnabledFlag(
|
||||
evalPreFill?: EvalTemplateFormPreFill,
|
||||
): UIModelParams {
|
||||
const defaultModelParams: ModelParams & { maxTemperature: number } = {
|
||||
model: evalPreFill?.model ?? "gpt-3.5-turbo",
|
||||
provider: ModelProvider.OpenAI,
|
||||
max_tokens: 100,
|
||||
maxTemperature:
|
||||
evalPreFill?.modelParams?.provider === ModelProvider.OpenAI ? 2 : 1,
|
||||
top_p: 1,
|
||||
temperature: 1,
|
||||
};
|
||||
|
||||
return Object.entries({
|
||||
...defaultModelParams,
|
||||
...evalPreFill?.modelParams,
|
||||
}).reduce(
|
||||
(params, [key, value]) => ({
|
||||
...params,
|
||||
[key]: {
|
||||
enabled: Boolean(
|
||||
!evalPreFill || evalPreFill.modelParams[key as keyof UIModelParams],
|
||||
),
|
||||
value,
|
||||
},
|
||||
}),
|
||||
{} as UIModelParams,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { throwIfNoAccess } from "@/src/features/rbac/utils/checkAccess";
|
||||
import { auditLog } from "@/src/features/audit-logs/auditLog";
|
||||
import { DEFAULT_TRACE_JOB_DELAY, EvalTargetObject } from "@langfuse/shared";
|
||||
import {
|
||||
EvalModelNames,
|
||||
ZodModelConfig,
|
||||
singleFilter,
|
||||
variableMapping,
|
||||
@@ -19,7 +18,8 @@ export const CreateEvalTemplate = z.object({
|
||||
name: z.string().min(1),
|
||||
projectId: z.string(),
|
||||
prompt: z.string(),
|
||||
model: EvalModelNames,
|
||||
provider: z.string(),
|
||||
model: z.string(),
|
||||
modelParams: ZodModelConfig,
|
||||
vars: z.array(z.string()),
|
||||
outputSchema: z.object({
|
||||
@@ -332,6 +332,7 @@ export const evalRouter = createTRPCRouter({
|
||||
modelParams: input.modelParams,
|
||||
vars: input.vars,
|
||||
outputSchema: input.outputSchema,
|
||||
provider: input.provider,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { Terminal } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createEmptyMessage } from "@/src/components/ChatMessages/utils/createEmptyMessage";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import usePlaygroundCache from "@/src/ee/features/playground/page/hooks/usePlaygroundCache";
|
||||
import { type PlaygroundCache } from "@/src/ee/features/playground/page/types";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { PromptType } from "@/src/features/prompts/server/utils/validation";
|
||||
import useProjectIdFromURL from "@/src/hooks/useProjectIdFromURL";
|
||||
import {
|
||||
ChatMessageRole,
|
||||
supportedModels as playgroundSupportedModels,
|
||||
type Observation,
|
||||
type Prompt,
|
||||
supportedModels as playgroundSupportedModels,
|
||||
type UIModelParams,
|
||||
ZodModelConfig,
|
||||
} from "@langfuse/shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { PromptType } from "@/src/features/prompts/server/utils/validation";
|
||||
import { ChatMessageListSchema } from "@/src/features/prompts/components/NewPromptForm/validation";
|
||||
import { createEmptyMessage } from "@/src/components/ChatMessages/utils/createEmptyMessage";
|
||||
import { getIsCloudEnvironment } from "@/src/ee/utils/getIsCloudEnvironment";
|
||||
import { useIsEeEnabled } from "@/src/ee/utils/useIsEeEnabled";
|
||||
|
||||
type JumpToPlaygroundButtonProps = (
|
||||
| {
|
||||
@@ -42,6 +42,7 @@ export const JumpToPlaygroundButton: React.FC<JumpToPlaygroundButtonProps> = (
|
||||
const projectId = useProjectIdFromURL();
|
||||
const { setPlaygroundCache } = usePlaygroundCache();
|
||||
const [capturedState, setCapturedState] = useState<PlaygroundCache>(null);
|
||||
const isEeEnabled = useIsEeEnabled();
|
||||
|
||||
useEffect(() => {
|
||||
if (props.source === "prompt") {
|
||||
@@ -56,7 +57,7 @@ export const JumpToPlaygroundButton: React.FC<JumpToPlaygroundButtonProps> = (
|
||||
setPlaygroundCache(capturedState);
|
||||
};
|
||||
|
||||
if (!getIsCloudEnvironment()) return null;
|
||||
if (!isEeEnabled) return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -76,9 +77,19 @@ export const JumpToPlaygroundButton: React.FC<JumpToPlaygroundButtonProps> = (
|
||||
);
|
||||
};
|
||||
|
||||
const ParsedChatMessageListSchema = z.array(
|
||||
z.object({
|
||||
role: z.nativeEnum(ChatMessageRole),
|
||||
content: z.union([
|
||||
z.string(),
|
||||
z.any().transform((v) => JSON.stringify(v, null, 2)),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
const parsePrompt = (prompt: Prompt): PlaygroundCache => {
|
||||
if (prompt.type === PromptType.Chat) {
|
||||
const parsedMessages = ChatMessageListSchema.safeParse(prompt.prompt);
|
||||
const parsedMessages = ParsedChatMessageListSchema.safeParse(prompt.prompt);
|
||||
|
||||
return parsedMessages.success ? { messages: parsedMessages.data } : null;
|
||||
} else {
|
||||
@@ -109,14 +120,16 @@ const parseGeneration = (generation: Observation): PlaygroundCache => {
|
||||
}
|
||||
|
||||
if (typeof input === "object") {
|
||||
const parsedMessages = ChatMessageListSchema.safeParse(input);
|
||||
const parsedMessages = ParsedChatMessageListSchema.safeParse(input);
|
||||
|
||||
if (parsedMessages.success)
|
||||
return { messages: parsedMessages.data, modelParams };
|
||||
}
|
||||
|
||||
if (typeof input === "object" && "messages" in input) {
|
||||
const parsedMessages = ChatMessageListSchema.safeParse(input["messages"]);
|
||||
const parsedMessages = ParsedChatMessageListSchema.safeParse(
|
||||
input["messages"],
|
||||
);
|
||||
|
||||
if (parsedMessages.success)
|
||||
return { messages: parsedMessages.data, modelParams };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type ChangeEvent } from "react";
|
||||
import { CheckCircle2, Circle, Trash2Icon } from "lucide-react";
|
||||
import { CheckCircle2, Circle, TrashIcon } from "lucide-react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Textarea } from "@/src/components/ui/textarea";
|
||||
import { type PromptVariable } from "@langfuse/shared";
|
||||
@@ -46,7 +46,7 @@ export const PromptVariableComponent: React.FC<{
|
||||
onClick={handleDeleteVariable}
|
||||
className="p-0"
|
||||
>
|
||||
{!isUsed && <Trash2Icon size={16} />}
|
||||
{!isUsed && <TrashIcon size={16} />}
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
|
||||
@@ -3,9 +3,10 @@ import { useRouter } from "next/router";
|
||||
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import usePlaygroundCache from "@/src/ee/features/playground/page/hooks/usePlaygroundCache";
|
||||
import { getIsCloudEnvironment } from "@/src/ee/utils/getIsCloudEnvironment";
|
||||
import { useIsEeEnabled } from "@/src/ee/utils/useIsEeEnabled";
|
||||
|
||||
export const ResetPlaygroundButton: React.FC = () => {
|
||||
const isEeEnabled = useIsEeEnabled();
|
||||
const router = useRouter();
|
||||
const { setPlaygroundCache } = usePlaygroundCache();
|
||||
|
||||
@@ -15,7 +16,9 @@ export const ResetPlaygroundButton: React.FC = () => {
|
||||
router.reload();
|
||||
};
|
||||
|
||||
return getIsCloudEnvironment() ? (
|
||||
if (!isEeEnabled) return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={"outline"}
|
||||
title="Reset playground state"
|
||||
@@ -24,5 +27,5 @@ export const ResetPlaygroundButton: React.FC = () => {
|
||||
<ListRestartIcon className="mr-1 h-5 w-5" />
|
||||
<span>Reset playground</span>
|
||||
</Button>
|
||||
) : null;
|
||||
);
|
||||
};
|
||||
|
||||
@@ -19,14 +19,15 @@ import {
|
||||
} from "@/src/components/ui/popover";
|
||||
import { usePlaygroundContext } from "@/src/ee/features/playground/page/context";
|
||||
import usePlaygroundCache from "@/src/ee/features/playground/page/hooks/usePlaygroundCache";
|
||||
import { getIsCloudEnvironment } from "@/src/ee/utils/getIsCloudEnvironment";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { PromptType } from "@/src/features/prompts/server/utils/validation";
|
||||
import useProjectIdFromURL from "@/src/hooks/useProjectIdFromURL";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { useIsEeEnabled } from "@/src/ee/utils/useIsEeEnabled";
|
||||
|
||||
export const SaveToPromptButton: React.FC = () => {
|
||||
const isEeEnabled = useIsEeEnabled();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedPromptId, setSelectedPromptId] = useState("");
|
||||
const { modelParams, messages, output, promptVariables } =
|
||||
@@ -83,7 +84,7 @@ export const SaveToPromptButton: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
if (!getIsCloudEnvironment()) return null;
|
||||
if (!isEeEnabled) return null;
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { createEmptyMessage } from "@/src/components/ChatMessages/utils/createEmptyMessage";
|
||||
import useCommandEnter from "@/src/ee/features/playground/page/hooks/useCommandEnter";
|
||||
import { useModelParams } from "@/src/ee/features/playground/page/hooks/useModelParams";
|
||||
import usePlaygroundCache from "@/src/ee/features/playground/page/hooks/usePlaygroundCache";
|
||||
import { getFinalModelParams } from "@/src/ee/utils/getFinalModelParams";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
@@ -19,7 +20,6 @@ import { extractVariables } from "@/src/utils/string";
|
||||
import {
|
||||
ChatMessageRole,
|
||||
type ChatMessageWithId,
|
||||
ModelProvider,
|
||||
type PromptVariable,
|
||||
type UIModelParams,
|
||||
} from "@langfuse/shared";
|
||||
@@ -68,9 +68,14 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
|
||||
createEmptyMessage(ChatMessageRole.System),
|
||||
createEmptyMessage(ChatMessageRole.User),
|
||||
]);
|
||||
const [modelParams, setModelParams] = useState<UIModelParams>(
|
||||
getDefaultModelParams(ModelProvider.OpenAI),
|
||||
);
|
||||
const {
|
||||
modelParams,
|
||||
setModelParams,
|
||||
availableProviders,
|
||||
availableModels,
|
||||
updateModelParamValue,
|
||||
setModelParamEnabled,
|
||||
} = useModelParams();
|
||||
|
||||
// Load state from cache
|
||||
useEffect(() => {
|
||||
@@ -97,11 +102,7 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
|
||||
if (cachedPromptVariables) {
|
||||
setPromptVariables(cachedPromptVariables);
|
||||
}
|
||||
}, [playgroundCache]);
|
||||
|
||||
useEffect(() => {
|
||||
setModelParams(getDefaultModelParams(modelParams.provider.value));
|
||||
}, [modelParams.provider.value]);
|
||||
}, [playgroundCache, setModelParams]);
|
||||
|
||||
const updatePromptVariables = useCallback(() => {
|
||||
const messageContents = messages.map((m) => m.content).join("\n");
|
||||
@@ -169,6 +170,10 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
|
||||
finalMessages.map((m) => m.content).join("\n"),
|
||||
);
|
||||
|
||||
if (!modelParams.provider.value || !modelParams.model.value) {
|
||||
throw new Error("Please select a model");
|
||||
}
|
||||
|
||||
if (leftOverVariables.length > 0) {
|
||||
throw Error("Error replacing variables. Please check your inputs.");
|
||||
}
|
||||
@@ -210,24 +215,6 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
|
||||
|
||||
useCommandEnter(!isStreaming, handleSubmit);
|
||||
|
||||
const updateModelParamValue: PlaygroundContextType["updateModelParamValue"] =
|
||||
(key, value) => {
|
||||
setModelParams((prev) => ({
|
||||
...prev,
|
||||
[key]: { ...prev[key], value },
|
||||
}));
|
||||
};
|
||||
|
||||
const setModelParamEnabled: PlaygroundContextType["setModelParamEnabled"] = (
|
||||
key,
|
||||
enabled,
|
||||
) => {
|
||||
setModelParams((prev) => ({
|
||||
...prev,
|
||||
[key]: { ...prev[key], enabled },
|
||||
}));
|
||||
};
|
||||
|
||||
const updatePromptVariableValue = (variable: string, value: string) => {
|
||||
setPromptVariables((prev) =>
|
||||
prev.map((v) => (v.name === variable ? { ...v, value } : v)),
|
||||
@@ -258,6 +245,9 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
|
||||
outputJson,
|
||||
handleSubmit,
|
||||
isStreaming,
|
||||
|
||||
availableProviders,
|
||||
availableModels,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@@ -342,38 +332,6 @@ function getFinalMessages(
|
||||
return finalMessages;
|
||||
}
|
||||
|
||||
function getDefaultModelParams(provider: ModelProvider): UIModelParams {
|
||||
switch (provider) {
|
||||
// Docs: https://platform.openai.com/docs/api-reference/chat/create
|
||||
case ModelProvider.OpenAI:
|
||||
return {
|
||||
provider: {
|
||||
value: provider,
|
||||
enabled: true,
|
||||
},
|
||||
model: { value: "gpt-3.5-turbo", enabled: true },
|
||||
temperature: { value: 1, enabled: true },
|
||||
maxTemperature: { value: 2, enabled: true },
|
||||
max_tokens: { value: 256, enabled: true },
|
||||
top_p: { value: 1, enabled: true },
|
||||
};
|
||||
|
||||
// Docs: https://docs.anthropic.com/claude/reference/messages_post
|
||||
case ModelProvider.Anthropic:
|
||||
return {
|
||||
provider: {
|
||||
value: provider,
|
||||
enabled: true,
|
||||
},
|
||||
model: { value: "claude-3-opus-20240229", enabled: true },
|
||||
temperature: { value: 0, enabled: true },
|
||||
maxTemperature: { value: 1, enabled: true },
|
||||
max_tokens: { value: 256, enabled: true },
|
||||
top_p: { value: 1, enabled: true },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getOutputJson(
|
||||
output: string,
|
||||
messages: ChatMessageWithId[],
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import useProjectIdFromURL from "@/src/hooks/useProjectIdFromURL";
|
||||
import { api } from "@/src/utils/api";
|
||||
import {
|
||||
LLMAdapter,
|
||||
evalModelsByAdapter,
|
||||
supportedModels,
|
||||
type UIModelParams,
|
||||
} from "@langfuse/shared";
|
||||
import { type ModelParamsContext } from "@/src/components/ModelParameters";
|
||||
|
||||
export const useModelParams = ({
|
||||
evalModelsOnly,
|
||||
}: {
|
||||
evalModelsOnly?: boolean;
|
||||
} = {}) => {
|
||||
const [modelParams, setModelParams] = useState<UIModelParams>({
|
||||
...getDefaultAdapterParams(LLMAdapter.OpenAI),
|
||||
provider: { value: "", enabled: true },
|
||||
model: { value: "", enabled: true },
|
||||
});
|
||||
|
||||
// Set initial model params
|
||||
const projectId = useProjectIdFromURL();
|
||||
const availableLLMApiKeys = api.llmApiKey.all.useQuery(
|
||||
{
|
||||
projectId: projectId as string,
|
||||
},
|
||||
{ enabled: Boolean(projectId) },
|
||||
);
|
||||
|
||||
const availableProviders = useMemo(() => {
|
||||
return availableLLMApiKeys.data?.data.map((key) => key.provider) ?? [];
|
||||
}, [availableLLMApiKeys]);
|
||||
|
||||
const selectedProviderApiKey = availableLLMApiKeys.data?.data.find(
|
||||
(key) => key.provider === modelParams.provider.value,
|
||||
);
|
||||
|
||||
const availableModels = useMemo(
|
||||
() =>
|
||||
!selectedProviderApiKey
|
||||
? []
|
||||
: evalModelsOnly
|
||||
? [
|
||||
...evalModelsByAdapter[selectedProviderApiKey.adapter],
|
||||
...selectedProviderApiKey.customModels,
|
||||
]
|
||||
: selectedProviderApiKey.withDefaultModels
|
||||
? [
|
||||
...selectedProviderApiKey.customModels,
|
||||
...supportedModels[selectedProviderApiKey.adapter],
|
||||
]
|
||||
: selectedProviderApiKey.customModels,
|
||||
[selectedProviderApiKey, evalModelsOnly],
|
||||
);
|
||||
|
||||
const updateModelParamValue: ModelParamsContext["updateModelParamValue"] = (
|
||||
key,
|
||||
value,
|
||||
) => {
|
||||
setModelParams((prev) => ({
|
||||
...prev,
|
||||
[key]: { ...prev[key], value },
|
||||
}));
|
||||
};
|
||||
|
||||
const setModelParamEnabled: ModelParamsContext["setModelParamEnabled"] = (
|
||||
key,
|
||||
enabled,
|
||||
) => {
|
||||
setModelParams((prev) => ({
|
||||
...prev,
|
||||
[key]: { ...prev[key], enabled },
|
||||
}));
|
||||
};
|
||||
|
||||
// Set default provider and model
|
||||
useEffect(() => {
|
||||
if (availableProviders.length > 0 && !modelParams.provider.value) {
|
||||
updateModelParamValue("provider", availableProviders[0]);
|
||||
}
|
||||
}, [availableProviders, modelParams.provider.value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
(availableModels.length > 0 && !modelParams.model.value) ||
|
||||
!availableModels.includes(modelParams.model.value)
|
||||
) {
|
||||
updateModelParamValue("model", availableModels[0]);
|
||||
}
|
||||
}, [availableModels, modelParams.model.value]);
|
||||
|
||||
// Update adapter and max temperature when provider changes
|
||||
useEffect(() => {
|
||||
if (selectedProviderApiKey?.adapter) {
|
||||
setModelParams((prev) => ({
|
||||
...prev,
|
||||
adapter: {
|
||||
value: selectedProviderApiKey.adapter,
|
||||
enabled: true,
|
||||
},
|
||||
maxTemperature: getDefaultAdapterParams(selectedProviderApiKey.adapter)
|
||||
.maxTemperature,
|
||||
temperature: {
|
||||
value: Math.min(
|
||||
prev.temperature.value,
|
||||
getDefaultAdapterParams(selectedProviderApiKey.adapter)
|
||||
.maxTemperature.value,
|
||||
),
|
||||
enabled: true,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}, [selectedProviderApiKey?.adapter]);
|
||||
|
||||
return {
|
||||
modelParams,
|
||||
setModelParams,
|
||||
availableProviders,
|
||||
availableModels,
|
||||
updateModelParamValue,
|
||||
setModelParamEnabled,
|
||||
};
|
||||
};
|
||||
|
||||
function getDefaultAdapterParams(
|
||||
adapter: LLMAdapter,
|
||||
): Omit<UIModelParams, "provider" | "model"> {
|
||||
switch (adapter) {
|
||||
// Docs: https://platform.openai.com/docs/api-reference/chat/create
|
||||
case LLMAdapter.OpenAI:
|
||||
return {
|
||||
adapter: {
|
||||
value: adapter,
|
||||
enabled: true,
|
||||
},
|
||||
temperature: { value: 0, enabled: true },
|
||||
maxTemperature: { value: 2, enabled: true },
|
||||
max_tokens: { value: 256, enabled: true },
|
||||
top_p: { value: 1, enabled: true },
|
||||
};
|
||||
|
||||
case LLMAdapter.Azure:
|
||||
return {
|
||||
adapter: {
|
||||
value: adapter,
|
||||
enabled: true,
|
||||
},
|
||||
temperature: { value: 0, enabled: true },
|
||||
maxTemperature: { value: 2, enabled: true },
|
||||
max_tokens: { value: 256, enabled: true },
|
||||
top_p: { value: 1, enabled: true },
|
||||
};
|
||||
|
||||
// Docs: https://docs.anthropic.com/claude/reference/messages_post
|
||||
case LLMAdapter.Anthropic:
|
||||
return {
|
||||
adapter: {
|
||||
value: adapter,
|
||||
enabled: true,
|
||||
},
|
||||
temperature: { value: 0, enabled: true },
|
||||
maxTemperature: { value: 1, enabled: true },
|
||||
max_tokens: { value: 256, enabled: true },
|
||||
top_p: { value: 1, enabled: true },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { getIsCloudEnvironment } from "@/src/ee/utils/getIsCloudEnvironment";
|
||||
import { type PlaygroundCache } from "../types";
|
||||
import { useIsEeEnabled } from "@/src/ee/utils/useIsEeEnabled";
|
||||
|
||||
const playgroundCacheKey = "playgroundCache";
|
||||
|
||||
export default function usePlaygroundCache() {
|
||||
const [cache, setCache] = useState<PlaygroundCache>(null);
|
||||
|
||||
const isEeEnabled = useIsEeEnabled();
|
||||
const setPlaygroundCache = (cache: PlaygroundCache) => {
|
||||
sessionStorage.setItem(playgroundCacheKey, JSON.stringify(cache));
|
||||
};
|
||||
@@ -24,7 +24,7 @@ export default function usePlaygroundCache() {
|
||||
}, []);
|
||||
|
||||
return {
|
||||
playgroundCache: getIsCloudEnvironment() ? cache : null,
|
||||
setPlaygroundCache: getIsCloudEnvironment() ? setPlaygroundCache : () => {},
|
||||
playgroundCache: isEeEnabled ? cache : null,
|
||||
setPlaygroundCache: isEeEnabled ? setPlaygroundCache : () => {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ import { ResetPlaygroundButton } from "@/src/ee/features/playground/page/compone
|
||||
import { SaveToPromptButton } from "@/src/ee/features/playground/page/components/SaveToPromptButton";
|
||||
import { PlaygroundProvider } from "@/src/ee/features/playground/page/context";
|
||||
import Playground from "@/src/ee/features/playground/page/playground";
|
||||
import { getIsCloudEnvironment } from "@/src/ee/utils/getIsCloudEnvironment";
|
||||
import { useIsEeEnabled } from "@/src/ee/utils/useIsEeEnabled";
|
||||
|
||||
export default function PlaygroundPage() {
|
||||
return getIsCloudEnvironment() ? (
|
||||
const isEeAvailable = useIsEeEnabled();
|
||||
if (!isEeAvailable) return null;
|
||||
return (
|
||||
<PlaygroundProvider>
|
||||
<div className="flex h-[95vh] flex-col">
|
||||
<Header
|
||||
@@ -28,5 +30,5 @@ export default function PlaygroundPage() {
|
||||
</div>
|
||||
</div>
|
||||
</PlaygroundProvider>
|
||||
) : null;
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
import { getIsCloudEnvironment } from "@/src/ee/utils/getIsCloudEnvironment";
|
||||
import { getAuthOptions } from "@/src/server/auth";
|
||||
import { isProjectMemberOrAdmin } from "@/src/server/utils/checkProjectMembershipOrAdmin";
|
||||
import { ApiError, ForbiddenError, UnauthorizedError } from "@langfuse/shared";
|
||||
import { isEeEnabled } from "@/src/ee/utils/isEeEnabled";
|
||||
|
||||
export type AuthorizeRequestResult = {
|
||||
userId: string;
|
||||
@@ -12,8 +12,10 @@ export type AuthorizeRequestResult = {
|
||||
export const authorizeRequestOrThrow = async (
|
||||
projectId: string,
|
||||
): Promise<AuthorizeRequestResult> => {
|
||||
if (!getIsCloudEnvironment())
|
||||
throw new ApiError("This endpoint is available in Langfuse cloud only.");
|
||||
if (!isEeEnabled)
|
||||
throw new ApiError(
|
||||
"LLM Playground is not yet available in the v2 open-source version.",
|
||||
);
|
||||
|
||||
const authOptions = await getAuthOptions();
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
@@ -39,6 +39,7 @@ export default async function chatCompletionHandler(req: NextRequest) {
|
||||
streaming: true,
|
||||
callbacks: [new PosthogCallbackHandler("playground", body, userId)],
|
||||
apiKey: decrypt(LLMApiKey.secretKey),
|
||||
baseURL: LLMApiKey.baseURL || undefined,
|
||||
});
|
||||
|
||||
return new StreamingTextResponse(stream);
|
||||
|
||||
@@ -1,31 +1,14 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ChatMessageRole,
|
||||
ModelProvider,
|
||||
anthropicModels,
|
||||
openAIModels,
|
||||
} from "@langfuse/shared";
|
||||
import { ChatMessageRole, LLMAdapter } from "@langfuse/shared";
|
||||
|
||||
const OpenAIModelSchema = z.enum([...openAIModels]);
|
||||
const AnthropicModelSchema = z.enum([...anthropicModels]);
|
||||
const OpenAIModelParamsSchema = z.object({
|
||||
provider: z.literal(ModelProvider.OpenAI),
|
||||
model: OpenAIModelSchema,
|
||||
max_tokens: z.number().optional(),
|
||||
temperature: z.number().optional(),
|
||||
top_p: z.number().optional(),
|
||||
});
|
||||
const AnthropicModelParamsSchema = z.object({
|
||||
provider: z.literal(ModelProvider.Anthropic),
|
||||
model: AnthropicModelSchema,
|
||||
const ModelParamsSchema = z.object({
|
||||
provider: z.string(),
|
||||
adapter: z.nativeEnum(LLMAdapter),
|
||||
model: z.string(),
|
||||
temperature: z.number().optional(),
|
||||
max_tokens: z.number().optional(),
|
||||
top_p: z.number().optional(),
|
||||
});
|
||||
const ModelParamsSchema = z.union([
|
||||
OpenAIModelParamsSchema,
|
||||
AnthropicModelParamsSchema,
|
||||
]);
|
||||
const MessageSchema = z.object({
|
||||
role: z.nativeEnum(ChatMessageRole),
|
||||
content: z.string(),
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import { env } from "@/src/env.mjs";
|
||||
|
||||
export const getIsCloudEnvironment = () =>
|
||||
Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION);
|
||||
@@ -0,0 +1,15 @@
|
||||
import { env } from "@/src/env.mjs";
|
||||
|
||||
/**
|
||||
* Function to determine if the enterprise edition (EE) features are enabled. Do not use in frontend code as env variables are not available in the browser.
|
||||
*
|
||||
* This hook checks two conditions:
|
||||
* 1. If Langfuse is running on Langfuse Cloud
|
||||
* 2. If EE license is provided based on the user's session
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
|
||||
export const isEeEnabled =
|
||||
Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION) ||
|
||||
Boolean(env.LANGFUSE_EE_LICENSE_KEY);
|
||||
@@ -0,0 +1,20 @@
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
/**
|
||||
* Custom React frontend hook to determine if the enterprise edition (EE) features are enabled.
|
||||
*
|
||||
* This hook checks two conditions:
|
||||
* 1. If Langfuse is running on Langfuse Cloud
|
||||
* 2. If EE license is provided based on the user's session
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
|
||||
export const useIsEeEnabled: () => boolean = () => {
|
||||
const session = useSession();
|
||||
return (
|
||||
Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION) ||
|
||||
Boolean(session.data?.environment.eeEnabled)
|
||||
);
|
||||
};
|
||||
+32
-14
@@ -30,14 +30,20 @@ export const env = createEnv({
|
||||
LANGFUSE_NEW_USER_SIGNUP_WEBHOOK: z.string().url().optional(),
|
||||
// Add `.min(1) on ID and SECRET if you want to make sure they're not empty
|
||||
LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES: z.enum(["true", "false"]).optional(),
|
||||
LANGFUSE_DEFAULT_TABLE_DATETIME_OFFSET: z.string().optional().refine((v) =>
|
||||
v === undefined || !isNaN(Number.parseInt(v))
|
||||
, {
|
||||
message: "LANGFUSE_DEFAULT_TABLE_DATETIME_OFFSET must be a number when set"
|
||||
}).transform((v) =>
|
||||
v === undefined ? undefined : -Number.parseInt(v) // negative offset
|
||||
LANGFUSE_DEFAULT_TABLE_DATETIME_OFFSET: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((v) => v === undefined || !isNaN(Number.parseInt(v)), {
|
||||
message:
|
||||
"LANGFUSE_DEFAULT_TABLE_DATETIME_OFFSET must be a number when set",
|
||||
})
|
||||
.transform(
|
||||
(v) => (v === undefined ? undefined : -Number.parseInt(v)), // negative offset
|
||||
),
|
||||
LANGFUSE_DISABLE_EXPENSIVE_POSTGRES_QUERIES: z.enum(["true", "false"]).optional().default("false"),
|
||||
LANGFUSE_DISABLE_EXPENSIVE_POSTGRES_QUERIES: z
|
||||
.enum(["true", "false"])
|
||||
.optional()
|
||||
.default("false"),
|
||||
SALT: z.string({
|
||||
required_error:
|
||||
"A strong Salt is required to encrypt API keys securely. See: https://langfuse.com/docs/deployment/self-host#deploy-the-container",
|
||||
@@ -51,6 +57,7 @@ export const env = createEnv({
|
||||
// AUTH
|
||||
AUTH_GOOGLE_CLIENT_ID: z.string().optional(),
|
||||
AUTH_GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||||
AUTH_GOOGLE_ALLOWED_DOMAINS: z.string().optional(),
|
||||
AUTH_GOOGLE_ALLOW_ACCOUNT_LINKING: z.enum(["true", "false"]).optional(),
|
||||
AUTH_GITHUB_CLIENT_ID: z.string().optional(),
|
||||
AUTH_GITHUB_CLIENT_SECRET: z.string().optional(),
|
||||
@@ -71,6 +78,11 @@ export const env = createEnv({
|
||||
AUTH_COGNITO_CLIENT_SECRET: z.string().optional(),
|
||||
AUTH_COGNITO_ISSUER: z.string().url().optional(),
|
||||
AUTH_COGNITO_ALLOW_ACCOUNT_LINKING: z.enum(["true", "false"]).optional(),
|
||||
AUTH_CUSTOM_CLIENT_ID: z.string().optional(),
|
||||
AUTH_CUSTOM_CLIENT_SECRET: z.string().optional(),
|
||||
AUTH_CUSTOM_ISSUER: z.string().url().optional(),
|
||||
AUTH_CUSTOM_NAME: z.string().optional(),
|
||||
AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING: z.enum(["true", "false"]).optional(),
|
||||
AUTH_DOMAINS_WITH_SSO_ENFORCEMENT: z.string().optional(),
|
||||
AUTH_DISABLE_USERNAME_PASSWORD: z.enum(["true", "false"]).optional(),
|
||||
AUTH_DISABLE_SIGNUP: z.enum(["true", "false"]).optional(),
|
||||
@@ -88,12 +100,11 @@ export const env = createEnv({
|
||||
// Worker
|
||||
LANGFUSE_WORKER_HOST: z.string().optional(),
|
||||
LANGFUSE_WORKER_PASSWORD: z.string().optional(),
|
||||
// Prompt playground
|
||||
OPENAI_API_KEY: z.string().optional(),
|
||||
ANTHROPIC_API_KEY: z.string().optional(),
|
||||
TURNSTILE_SECRET_KEY: z.string().optional(),
|
||||
// DB event log
|
||||
ENABLE_EVENT_LOG: z.enum(["true", "false"]).optional().default("true"),
|
||||
// EE License
|
||||
LANGFUSE_EE_LICENSE_KEY: z.string().optional(),
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -150,6 +161,7 @@ export const env = createEnv({
|
||||
// AUTH
|
||||
AUTH_GOOGLE_CLIENT_ID: process.env.AUTH_GOOGLE_CLIENT_ID,
|
||||
AUTH_GOOGLE_CLIENT_SECRET: process.env.AUTH_GOOGLE_CLIENT_SECRET,
|
||||
AUTH_GOOGLE_ALLOWED_DOMAINS: process.env.AUTH_GOOGLE_ALLOWED_DOMAINS,
|
||||
AUTH_GOOGLE_ALLOW_ACCOUNT_LINKING:
|
||||
process.env.AUTH_GOOGLE_ALLOW_ACCOUNT_LINKING,
|
||||
AUTH_GITHUB_CLIENT_ID: process.env.AUTH_GITHUB_CLIENT_ID,
|
||||
@@ -174,7 +186,14 @@ export const env = createEnv({
|
||||
AUTH_COGNITO_CLIENT_ID: process.env.AUTH_COGNITO_CLIENT_ID,
|
||||
AUTH_COGNITO_CLIENT_SECRET: process.env.AUTH_COGNITO_CLIENT_SECRET,
|
||||
AUTH_COGNITO_ISSUER: process.env.AUTH_COGNITO_ISSUER,
|
||||
AUTH_COGNITO_ALLOW_ACCOUNT_LINKING: process.env.AUTH_COGNITO_ALLOW_ACCOUNT_LINKING,
|
||||
AUTH_COGNITO_ALLOW_ACCOUNT_LINKING:
|
||||
process.env.AUTH_COGNITO_ALLOW_ACCOUNT_LINKING,
|
||||
AUTH_CUSTOM_CLIENT_ID: process.env.AUTH_CUSTOM_CLIENT_ID,
|
||||
AUTH_CUSTOM_CLIENT_SECRET: process.env.AUTH_CUSTOM_CLIENT_SECRET,
|
||||
AUTH_CUSTOM_ISSUER: process.env.AUTH_CUSTOM_ISSUER,
|
||||
AUTH_CUSTOM_NAME: process.env.AUTH_CUSTOM_NAME,
|
||||
AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING:
|
||||
process.env.AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING,
|
||||
AUTH_DOMAINS_WITH_SSO_ENFORCEMENT:
|
||||
process.env.AUTH_DOMAINS_WITH_SSO_ENFORCEMENT,
|
||||
AUTH_DISABLE_USERNAME_PASSWORD: process.env.AUTH_DISABLE_USERNAME_PASSWORD,
|
||||
@@ -193,9 +212,6 @@ export const env = createEnv({
|
||||
// Worker
|
||||
LANGFUSE_WORKER_HOST: process.env.LANGFUSE_WORKER_HOST,
|
||||
LANGFUSE_WORKER_PASSWORD: process.env.LANGFUSE_WORKER_PASSWORD,
|
||||
// Prompt playground
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
|
||||
TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY,
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY,
|
||||
NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,
|
||||
@@ -204,6 +220,8 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_CRISP_WEBSITE_ID: process.env.NEXT_PUBLIC_CRISP_WEBSITE_ID,
|
||||
// db event log
|
||||
ENABLE_EVENT_LOG: process.env.ENABLE_EVENT_LOG,
|
||||
// EE License
|
||||
LANGFUSE_EE_LICENSE_KEY: process.env.LANGFUSE_EE_LICENSE_KEY,
|
||||
},
|
||||
// Skip validation in Docker builds
|
||||
// DOCKER_BUILD is set in Dockerfile
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { noHtmlCheck } from "@/src/utils/zod";
|
||||
import { noHtmlCheck } from "@langfuse/shared";
|
||||
import * as z from "zod";
|
||||
|
||||
export const projectNameSchema = z.object({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { noHtmlCheck } from "@/src/utils/zod";
|
||||
import { noHtmlCheck } from "@langfuse/shared";
|
||||
import * as z from "zod";
|
||||
|
||||
export const signupSchema = z.object({
|
||||
|
||||
@@ -3,6 +3,10 @@ import { getColorsForCategories } from "@/src/features/dashboard/utils/getColors
|
||||
import { compactNumberFormatter } from "@/src/utils/numbers";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { AreaChart, LineChart } from "@tremor/react";
|
||||
import {
|
||||
CurrencyTooltip,
|
||||
NumberTooltip,
|
||||
} from "@/src/features/dashboard/components/Tooltip";
|
||||
|
||||
export type TimeSeriesChartDataPoint = {
|
||||
ts: number;
|
||||
@@ -58,6 +62,9 @@ export function BaseTimeSeriesChart(props: {
|
||||
};
|
||||
|
||||
const ChartComponent = props.chartType === "area" ? AreaChart : LineChart;
|
||||
const TooltipComponent = props.valueFormatter
|
||||
? CurrencyTooltip
|
||||
: NumberTooltip;
|
||||
const colors = getColorsForCategories(Array.from(labels));
|
||||
|
||||
return (
|
||||
@@ -74,6 +81,7 @@ export function BaseTimeSeriesChart(props: {
|
||||
showAnimation={true}
|
||||
onValueChange={() => {}}
|
||||
enableLegendSlider={true}
|
||||
customTooltip={TooltipComponent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export const TabComponent = ({ tabs }: TabComponentProps) => {
|
||||
<select
|
||||
id="tabs"
|
||||
name="tabs"
|
||||
className="focus:border-primary-accent focus:ring-primary-accent block w-full rounded-md border-border py-2 pl-3 pr-10 text-base focus:outline-none sm:text-sm"
|
||||
className="block w-full rounded-md border-border bg-background py-2 pl-3 pr-10 text-base focus:border-primary-accent focus:outline-none focus:ring-primary-accent sm:text-sm"
|
||||
defaultValue={0}
|
||||
onChange={(e) => setSelectedIndex(Number(e.target.selectedIndex))}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
ChartTooltipFrame,
|
||||
ChartTooltipRow,
|
||||
} from "@tremor/react/dist/components/chart-elements/common/ChartTooltip";
|
||||
import { compactNumberFormatter, usdFormatter } from "@/src/utils/numbers";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { type CustomTooltipProps } from "@tremor/react";
|
||||
import { getRandomColor } from "@/src/features/dashboard/utils/getColorsForCategories";
|
||||
|
||||
export const Tooltip = ({
|
||||
payload,
|
||||
active,
|
||||
label,
|
||||
formatter,
|
||||
}: CustomTooltipProps & { formatter: (value: number) => string }) => {
|
||||
if (!active || !payload) return null;
|
||||
|
||||
// Filter out duplicates and sort by value in descending order
|
||||
const uniquePayload = Array.from(
|
||||
new Map(payload.map((category) => [category.name, category])).values(),
|
||||
);
|
||||
|
||||
const sortedPayload = uniquePayload.sort(
|
||||
(a, b) => (Number(b.value) ?? 0) - (Number(a.value) ?? 0),
|
||||
);
|
||||
|
||||
return (
|
||||
<ChartTooltipFrame>
|
||||
<div
|
||||
className={cn(
|
||||
// light
|
||||
"border-b border-tremor-border px-4 py-2",
|
||||
// dark
|
||||
"dark:border-dark-tremor-border",
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
// common
|
||||
"font-medium",
|
||||
// light
|
||||
"text-tremor-content-emphasis",
|
||||
// dark
|
||||
"dark:text-dark-tremor-content-emphasis",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={cn("space-y-1 px-4 py-2")}>
|
||||
{sortedPayload.map(({ name, value, color }, index) => (
|
||||
<ChartTooltipRow
|
||||
key={`${index}`}
|
||||
value={formatter(Number(value))}
|
||||
name={name?.toString() ?? ""}
|
||||
color={color ?? getRandomColor()}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ChartTooltipFrame>
|
||||
);
|
||||
};
|
||||
|
||||
export const NumberTooltip = (props: CustomTooltipProps) => (
|
||||
<Tooltip {...props} formatter={compactNumberFormatter} />
|
||||
);
|
||||
|
||||
export const CurrencyTooltip = (props: CustomTooltipProps) => (
|
||||
<Tooltip {...props} formatter={usdFormatter} />
|
||||
);
|
||||
@@ -91,7 +91,7 @@ export const TracesBarListChart = ({
|
||||
<BarList
|
||||
data={adjustedData}
|
||||
valueFormatter={(number: number) =>
|
||||
Intl.NumberFormat("us").format(number).toString()
|
||||
Intl.NumberFormat("en-US").format(number).toString()
|
||||
}
|
||||
className="mt-6"
|
||||
showAnimation={true}
|
||||
|
||||
@@ -47,7 +47,7 @@ const predefinedColors: Color[] = [
|
||||
"stone",
|
||||
];
|
||||
|
||||
function getRandomColor() {
|
||||
export function getRandomColor() {
|
||||
return predefinedColors[Math.floor(Math.random() * predefinedColors.length)];
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { throwIfNoAccess } from "@/src/features/rbac/utils/checkAccess";
|
||||
import { auditLog } from "@/src/features/audit-logs/auditLog";
|
||||
import { DB } from "@/src/server/db";
|
||||
import { paginationZod } from "@/src/utils/zod";
|
||||
import { paginationZod } from "@langfuse/shared";
|
||||
|
||||
export const datasetRouter = createTRPCRouter({
|
||||
allDatasetMeta: protectedProjectProcedure
|
||||
@@ -530,18 +530,6 @@ export const datasetRouter = createTRPCRouter({
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
observation: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
trace: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
@@ -549,6 +537,8 @@ export const datasetRouter = createTRPCRouter({
|
||||
skip: input.page * input.limit,
|
||||
});
|
||||
|
||||
if (runItems.length === 0) return { totalRunItems: 0, runItems: [] };
|
||||
|
||||
const traceScores = await ctx.prisma.score.findMany({
|
||||
where: {
|
||||
projectId: ctx.session.projectId,
|
||||
@@ -598,18 +588,37 @@ export const datasetRouter = createTRPCRouter({
|
||||
},
|
||||
});
|
||||
|
||||
const traces = await ctx.prisma.traceView.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: runItems.map((ri) => ri.traceId).filter(Boolean) as string[],
|
||||
},
|
||||
projectId: ctx.session.projectId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
duration: true,
|
||||
},
|
||||
});
|
||||
// Directly access 'traces' table and calculate duration via lateral join
|
||||
// Previously used 'traces_view' was not performant enough
|
||||
const traceIdsSQL = Prisma.sql`ARRAY[${Prisma.join(runItems.map((ri) => ri.traceId))}]`;
|
||||
const traces = await ctx.prisma.$queryRaw<
|
||||
{
|
||||
id: string;
|
||||
duration: number;
|
||||
}[]
|
||||
>(
|
||||
Prisma.sql`
|
||||
SELECT
|
||||
t.id,
|
||||
o.duration
|
||||
FROM
|
||||
traces t
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
EXTRACT(epoch FROM COALESCE(max(o1.end_time), max(o1.start_time)))::double precision - EXTRACT(epoch FROM min(o1.start_time))::double precision AS duration
|
||||
FROM
|
||||
observations o1
|
||||
WHERE
|
||||
o1.project_id = t.project_id
|
||||
AND o1.trace_id = t.id
|
||||
GROUP BY
|
||||
o1.project_id,
|
||||
o1.trace_id) o ON TRUE
|
||||
WHERE
|
||||
t.project_id = ${input.projectId}
|
||||
AND t.id = ANY(${traceIdsSQL})
|
||||
`,
|
||||
);
|
||||
|
||||
const items = runItems.map((ri) => {
|
||||
return {
|
||||
@@ -629,6 +638,7 @@ export const datasetRouter = createTRPCRouter({
|
||||
};
|
||||
});
|
||||
|
||||
// Note: We early return in case of no run items, when adding parameters here, make sure to update the early return above
|
||||
return {
|
||||
totalRunItems,
|
||||
runItems: items,
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
filterOperators,
|
||||
singleFilter,
|
||||
} from "@langfuse/shared";
|
||||
import { NonEmptyString } from "@/src/utils/zod";
|
||||
import { NonEmptyString } from "@langfuse/shared";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user