Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfef594484 | ||
|
|
3b4a66999e | ||
|
|
41b5385f73 | ||
|
|
bdd27ec829 | ||
|
|
bd68c0ee23 | ||
|
|
20f1097810 | ||
|
|
f3cb76e35b | ||
|
|
1a8061bc34 | ||
|
|
c91a641fa9 | ||
|
|
0ca75cae70 | ||
|
|
cbffe1dde4 | ||
|
|
d1a537c32c | ||
|
|
2f1ae6b7c2 | ||
|
|
48f3c170a0 | ||
|
|
9144dc6aba | ||
|
|
277abb5e15 | ||
|
|
b7595715ff | ||
|
|
592e575ae3 | ||
|
|
68cfbc1cd5 | ||
|
|
fa52cc74e1 | ||
|
|
48e84ee58d | ||
|
|
11763882f8 | ||
|
|
6a74c141d5 | ||
|
|
df5bf34218 | ||
|
|
3c54be90f7 | ||
|
|
38c035022f | ||
|
|
b9e200f58a | ||
|
|
1552bb1874 | ||
|
|
ca8017f958 | ||
|
|
1d2b2b040d | ||
|
|
e3a400b1ac | ||
|
|
62fad7cb48 | ||
|
|
6470f8e41b | ||
|
|
7a6e3f3f62 | ||
|
|
890d98d32d | ||
|
|
ef8e8fbdf6 | ||
|
|
d78dd1ff52 | ||
|
|
cf4c66e977 | ||
|
|
15b2dedd42 | ||
|
|
117f19e6cf | ||
|
|
69c2b3535d | ||
|
|
e703c25bc6 | ||
|
|
8435eb6fa1 | ||
|
|
a5da0bd863 | ||
|
|
47f296b2fb | ||
|
|
584a73ff53 | ||
|
|
c4ba6846b8 | ||
|
|
286b1af431 | ||
|
|
884772402c | ||
|
|
53f188d131 | ||
|
|
7f0f7a683f | ||
|
|
ddd5aeda18 | ||
|
|
b78f48e0b3 | ||
|
|
c75da83217 | ||
|
|
4679d1af14 | ||
|
|
f084d89918 | ||
|
|
d284a8e076 | ||
|
|
55e14e85f3 | ||
|
|
44435ca291 | ||
|
|
39d88d0cfd | ||
|
|
1fc3c6eca6 | ||
|
|
86e26e5e54 | ||
|
|
a2c925ab7b | ||
|
|
a3f7412653 | ||
|
|
d3a47c44d6 | ||
|
|
937ef0f0d8 | ||
|
|
75064e8d42 | ||
|
|
a464a12efb | ||
|
|
1823b4e94f | ||
|
|
f5ff37fc34 | ||
|
|
39d690072f | ||
|
|
d4c361f3b5 | ||
|
|
330c247bfb | ||
|
|
43062cfa5e | ||
|
|
8c5d4e5902 | ||
|
|
a382542fd7 | ||
|
|
92c8155ee9 | ||
|
|
fb2a276496 | ||
|
|
9b59decccf | ||
|
|
9289698825 | ||
|
|
ad4d6ca8aa | ||
|
|
790c65b951 | ||
|
|
55819f81d4 | ||
|
|
76a4380d9e | ||
|
|
6c745bc4dc | ||
|
|
8da1f80314 | ||
|
|
55409ef473 | ||
|
|
ad5f082926 | ||
|
|
88d4c68c2a | ||
|
|
3e0325bb14 | ||
|
|
ce67bb16e8 | ||
|
|
75f78e1a17 | ||
|
|
98a7088be7 | ||
|
|
28c186b508 | ||
|
|
bba56046e4 | ||
|
|
948a627c72 | ||
|
|
bdda312f5e | ||
|
|
4cfcd1f779 | ||
|
|
b3fc76186f | ||
|
|
fecaf7d49d | ||
|
|
1d9c2a9225 | ||
|
|
7a3b13d55e | ||
|
|
bc7f92665e | ||
|
|
509e511b6e | ||
|
|
ab9f8751b7 | ||
|
|
5ea5cde253 | ||
|
|
f28d79673a | ||
|
|
40a6236aa3 | ||
|
|
97c385f5ca | ||
|
|
7cf8015c07 | ||
|
|
5874ec5acc | ||
|
|
01844deb23 | ||
|
|
f7a90ffe65 | ||
|
|
7f7a9341da | ||
|
|
13978c0ca9 | ||
|
|
d564d3c488 | ||
|
|
bd933e5984 | ||
|
|
c0da836191 | ||
|
|
abb45c9481 | ||
|
|
a09e9483c8 | ||
|
|
5a61a9571c | ||
|
|
7b9a02ab9a | ||
|
|
03d80356bf | ||
|
|
35e0ea4c30 | ||
|
|
cf44eff091 | ||
|
|
e3c4d52bb4 | ||
|
|
da83b67e89 | ||
|
|
a13de116e8 | ||
|
|
725beb1d98 | ||
|
|
21c1016292 | ||
|
|
bc76e6fc76 | ||
|
|
24f9b14e69 | ||
|
|
ac0ecfcb84 | ||
|
|
e547011c96 | ||
|
|
164f073b50 | ||
|
|
6df405adcd | ||
|
|
f50dbdd9a6 | ||
|
|
b4274bda4a | ||
|
|
c9adc16cad | ||
|
|
f30cdbbee5 | ||
|
|
a307d64b07 | ||
|
|
c32bb30154 | ||
|
|
c27a62d271 | ||
|
|
102a37a48c | ||
|
|
6beb670744 | ||
|
|
82e81ddf75 | ||
|
|
1ad8ff3fa0 | ||
|
|
631dc417b2 | ||
|
|
57371b1c9d | ||
|
|
0076f411b0 | ||
|
|
9b04ea848c | ||
|
|
e30e9c7b3e | ||
|
|
f057352fc3 | ||
|
|
a0db49f282 | ||
|
|
c09943eb08 | ||
|
|
0e2ee1f394 | ||
|
|
c6f55d8646 | ||
|
|
14519c41c5 | ||
|
|
1f80429f97 | ||
|
|
800b3481d7 | ||
|
|
7e3fa15425 | ||
|
|
07db3af7fe | ||
|
|
85e68e433e | ||
|
|
5527bb30dd | ||
|
|
1b03d838db | ||
|
|
42f591214c | ||
|
|
668f6b8951 | ||
|
|
7895e104b0 | ||
|
|
bfd2a2dd37 | ||
|
|
cf5d3094ae | ||
|
|
7a3dd59128 | ||
|
|
f117a01fac | ||
|
|
2b4d64c9cf |
@@ -1,4 +0,0 @@
|
||||
[codespell]
|
||||
skip = .git,*.pdf,*.svg,package-lock.json,*.prisma,pnpm-lock.yaml
|
||||
ignore-words-list = afterall,vertx,notIn
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
node_modules
|
||||
npm-debug.log
|
||||
README.md
|
||||
**/.next
|
||||
.git
|
||||
**/node_modules
|
||||
@@ -1,92 +0,0 @@
|
||||
# When adding additional environment variables, the schema in "/src/env.mjs"
|
||||
# should be updated accordingly.
|
||||
|
||||
# Prisma
|
||||
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env
|
||||
DIRECT_URL="postgresql://postgres:postgres@localhost:5432/postgres"
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
|
||||
|
||||
# Clickhouse
|
||||
CLICKHOUSE_MIGRATION_URL="clickhouse://localhost:9000"
|
||||
CLICKHOUSE_URL="http://localhost:8123"
|
||||
CLICKHOUSE_USER="clickhouse"
|
||||
CLICKHOUSE_PASSWORD="clickhouse"
|
||||
CLICKHOUSE_MIGRATION_CLUSTER_DISABLED="true"
|
||||
|
||||
# Next Auth
|
||||
# You can generate a new secret on the command line with:
|
||||
# openssl rand -base64 32
|
||||
# https://next-auth.js.org/configuration/options#secret
|
||||
# NEXTAUTH_SECRET=""
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
NEXTAUTH_SECRET="secret"
|
||||
|
||||
# Langfuse Cloud Environment
|
||||
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION="DEV"
|
||||
|
||||
# Langfuse experimental features
|
||||
LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES="true"
|
||||
|
||||
# Salt for API key hashing
|
||||
SALT="salt"
|
||||
|
||||
# Email
|
||||
EMAIL_FROM_ADDRESS="" # Defines the email address to use as the from address.
|
||||
SMTP_CONNECTION_URL="" # Defines the connection url for smtp server.
|
||||
|
||||
# DON'T PANIC: The Azurite Secrets are well-known and meant to be hard-coded
|
||||
# S3 storage
|
||||
S3_ENDPOINT=http://localhost:10000/devstoreaccount1
|
||||
S3_ACCESS_KEY_ID=devstoreaccount1
|
||||
S3_SECRET_ACCESS_KEY=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==
|
||||
S3_BUCKET_NAME=langfuse
|
||||
S3_REGION=auto
|
||||
## Necessary for minio compatibility
|
||||
S3_FORCE_PATH_STYLE=true
|
||||
|
||||
# S3 Media Upload LOCAL
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_ENABLED=true
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_BUCKET=langfuse
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID=devstoreaccount1
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_REGION=auto
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT=http://localhost:10000/devstoreaccount1
|
||||
## Necessary for minio compatibility
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE=true
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_PREFIX=media/
|
||||
|
||||
# S3 Event Bucket Upload
|
||||
## Set to true to test uploading all events to S3
|
||||
LANGFUSE_S3_EVENT_UPLOAD_ENABLED=true
|
||||
LANGFUSE_S3_EVENT_UPLOAD_BUCKET=langfuse
|
||||
LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID=devstoreaccount1
|
||||
LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==
|
||||
LANGFUSE_S3_EVENT_UPLOAD_REGION=auto
|
||||
LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT=http://localhost:10000/devstoreaccount1
|
||||
## Necessary for minio compatibility
|
||||
LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE=true
|
||||
LANGFUSE_S3_EVENT_UPLOAD_PREFIX=events/
|
||||
|
||||
LANGFUSE_USE_AZURE_BLOB=true
|
||||
|
||||
# Set during docker build of application
|
||||
# Used to disable environment verification at build time
|
||||
# DOCKER_BUILD=1
|
||||
|
||||
REDIS_HOST="127.0.0.1"
|
||||
REDIS_PORT=6379
|
||||
REDIS_AUTH="myredissecret"
|
||||
|
||||
# openssl rand -hex 32 used only here
|
||||
ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
|
||||
|
||||
# speeds up local development by not executing init scripts on server startup
|
||||
NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT="false"
|
||||
|
||||
LANGFUSE_READ_FROM_POSTGRES_ONLY=false
|
||||
LANGFUSE_RETURN_FROM_CLICKHOUSE=true
|
||||
LANGFUSE_READ_DASHBOARDS_FROM_CLICKHOUSE=true
|
||||
LANGFUSE_READ_FROM_CLICKHOUSE_ONLY=true
|
||||
|
||||
LANGFUSE_ASYNC_INGESTION_PROCESSING="true"
|
||||
LANGFUSE_ASYNC_CLICKHOUSE_INGESTION_PROCESSING="true"
|
||||
@@ -1,89 +0,0 @@
|
||||
# When adding additional environment variables, the schema in "/src/env.mjs"
|
||||
# should be updated accordingly.
|
||||
|
||||
# Prisma
|
||||
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env
|
||||
DIRECT_URL="postgresql://postgres:postgres@localhost:5432/postgres"
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
|
||||
|
||||
# Clickhouse
|
||||
CLICKHOUSE_MIGRATION_URL="clickhouse://localhost:9000"
|
||||
CLICKHOUSE_URL="http://localhost:8123"
|
||||
CLICKHOUSE_USER="clickhouse"
|
||||
CLICKHOUSE_PASSWORD="clickhouse"
|
||||
CLICKHOUSE_CLUSTER_DISABLED="true"
|
||||
|
||||
# Next Auth
|
||||
# You can generate a new secret on the command line with:
|
||||
# openssl rand -base64 32
|
||||
# https://next-auth.js.org/configuration/options#secret
|
||||
# NEXTAUTH_SECRET=""
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
NEXTAUTH_SECRET="secret"
|
||||
|
||||
# Langfuse Cloud Environment
|
||||
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION="DEV"
|
||||
|
||||
# Langfuse experimental features
|
||||
LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES="true"
|
||||
|
||||
# Salt for API key hashing
|
||||
SALT="salt"
|
||||
|
||||
# Email
|
||||
EMAIL_FROM_ADDRESS="" # Defines the email address to use as the from address.
|
||||
SMTP_CONNECTION_URL="" # Defines the connection url for smtp server.
|
||||
|
||||
# S3 storage
|
||||
S3_ENDPOINT=http://localhost:9090
|
||||
S3_ACCESS_KEY_ID=minio
|
||||
S3_SECRET_ACCESS_KEY=miniosecret
|
||||
S3_BUCKET_NAME=langfuse
|
||||
S3_REGION=us-east-1
|
||||
## Necessary for minio compatibility
|
||||
S3_FORCE_PATH_STYLE=true
|
||||
|
||||
# S3 Media Upload LOCAL
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_ENABLED=true
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_BUCKET=langfuse
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID=minio
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY=miniosecret
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_REGION=us-east-1
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT=http://localhost:9090
|
||||
## Necessary for minio compatibility
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE=true
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_PREFIX=media/
|
||||
|
||||
# S3 Event Bucket Upload
|
||||
## Set to true to test uploading all events to S3
|
||||
LANGFUSE_S3_EVENT_UPLOAD_ENABLED=true
|
||||
LANGFUSE_S3_EVENT_UPLOAD_BUCKET=langfuse
|
||||
LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID=minio
|
||||
LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY=miniosecret
|
||||
LANGFUSE_S3_EVENT_UPLOAD_REGION=us-east-1
|
||||
LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT=http://localhost:9090
|
||||
## Necessary for minio compatibility
|
||||
LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE=true
|
||||
LANGFUSE_S3_EVENT_UPLOAD_PREFIX=events/
|
||||
|
||||
# Set during docker build of application
|
||||
# Used to disable environment verification at build time
|
||||
# DOCKER_BUILD=1
|
||||
|
||||
REDIS_HOST="127.0.0.1"
|
||||
REDIS_PORT=6379
|
||||
REDIS_AUTH="myredissecret"
|
||||
|
||||
# openssl rand -hex 32 used only here
|
||||
ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
|
||||
|
||||
# speeds up local development by not executing init scripts on server startup
|
||||
NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT="false"
|
||||
|
||||
LANGFUSE_READ_FROM_POSTGRES_ONLY=false
|
||||
LANGFUSE_RETURN_FROM_CLICKHOUSE=true
|
||||
LANGFUSE_READ_DASHBOARDS_FROM_CLICKHOUSE=true
|
||||
LANGFUSE_READ_FROM_CLICKHOUSE_ONLY=true
|
||||
|
||||
LANGFUSE_ASYNC_INGESTION_PROCESSING="true"
|
||||
LANGFUSE_ASYNC_CLICKHOUSE_INGESTION_PROCESSING="true"
|
||||
@@ -1,81 +0,0 @@
|
||||
# When adding additional environment variables, the schema in "/src/env.mjs"
|
||||
# should be updated accordingly.
|
||||
|
||||
# Prisma
|
||||
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env
|
||||
DIRECT_URL="postgresql://postgres:postgres@localhost:5432/postgres"
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
|
||||
|
||||
# Clickhouse
|
||||
CLICKHOUSE_MIGRATION_URL="clickhouse://localhost:9000"
|
||||
CLICKHOUSE_URL="http://localhost:8123"
|
||||
CLICKHOUSE_USER="clickhouse"
|
||||
CLICKHOUSE_PASSWORD="clickhouse"
|
||||
CLICKHOUSE_CLUSTER_ENABLED="false"
|
||||
|
||||
# Next Auth
|
||||
# You can generate a new secret on the command line with:
|
||||
# openssl rand -base64 32
|
||||
# https://next-auth.js.org/configuration/options#secret
|
||||
# NEXTAUTH_SECRET=""
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
NEXTAUTH_SECRET="secret"
|
||||
|
||||
# Langfuse Cloud Environment
|
||||
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION="DEV"
|
||||
|
||||
# Langfuse experimental features
|
||||
LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES="true"
|
||||
|
||||
# Salt for API key hashing
|
||||
SALT="salt"
|
||||
|
||||
# Email
|
||||
EMAIL_FROM_ADDRESS="" # Defines the email address to use as the from address.
|
||||
SMTP_CONNECTION_URL="" # Defines the connection url for smtp server.
|
||||
|
||||
# S3 storage
|
||||
S3_ENDPOINT=http://localhost:9090
|
||||
S3_ACCESS_KEY_ID=minio
|
||||
S3_SECRET_ACCESS_KEY=miniosecret
|
||||
S3_BUCKET_NAME=langfuse
|
||||
S3_REGION=us-east-1
|
||||
## Necessary for minio compatibility
|
||||
S3_FORCE_PATH_STYLE=true
|
||||
|
||||
# # S3 Media Upload LOCAL
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_ENABLED=true
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_BUCKET=langfuse
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID=minio
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY=miniosecret
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_REGION=us-east-1
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT=http://localhost:9090
|
||||
## Necessary for minio compatibility
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE=true
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_PREFIX=media/
|
||||
|
||||
# S3 Event Bucket Upload
|
||||
## Set to true to test uploading all events to S3
|
||||
LANGFUSE_S3_EVENT_UPLOAD_ENABLED=true
|
||||
LANGFUSE_S3_EVENT_UPLOAD_BUCKET=langfuse
|
||||
LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID=minio
|
||||
LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY=miniosecret
|
||||
LANGFUSE_S3_EVENT_UPLOAD_REGION=us-east-1
|
||||
LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT=http://localhost:9090
|
||||
## Necessary for minio compatibility
|
||||
LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE=true
|
||||
LANGFUSE_S3_EVENT_UPLOAD_PREFIX=events/
|
||||
|
||||
# Set during docker build of application
|
||||
# Used to disable environment verification at build time
|
||||
# DOCKER_BUILD=1
|
||||
|
||||
REDIS_HOST="127.0.0.1"
|
||||
REDIS_PORT=6379
|
||||
REDIS_AUTH="myredissecret"
|
||||
|
||||
# openssl rand -hex 32 used only here
|
||||
ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
|
||||
|
||||
# speeds up local development by not executing init scripts on server startup
|
||||
NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT="false"
|
||||
@@ -0,0 +1,24 @@
|
||||
# Hanzo Cloud Console — environment.
|
||||
# Copy to .env.local and adjust. All values are public (NEXT_PUBLIC_*) since the
|
||||
# console is a browser app that talks to the unified /v1 backend with cookies.
|
||||
|
||||
# Unified Hanzo Cloud backend (the casibase /v1 API). Default: production.
|
||||
# Local backend: http://localhost:14000
|
||||
NEXT_PUBLIC_CLOUD_URL=https://cloud.hanzo.ai
|
||||
|
||||
# Hanzo PaaS (platform.hanzo.ai) — DOKS cluster control plane for the Clusters module.
|
||||
NEXT_PUBLIC_PLATFORM_URL=https://platform.hanzo.ai
|
||||
|
||||
# Hanzo IAM (OIDC authority). Canonical issuer is https://hanzo.id — tokens are
|
||||
# minted with iss=https://hanzo.id, which the cloud /v1 backend validates against.
|
||||
# iam.hanzo.ai is the legacy zone (iss=https://iam.hanzo.ai) and MUST NOT be used
|
||||
# by the browser, or sign-in drops on iam.hanzo.ai with an issuer mismatch.
|
||||
NEXT_PUBLIC_IAM_URL=https://hanzo.id
|
||||
|
||||
# IAM application console2 authenticates as. console2 is a front-end of the
|
||||
# shared Hanzo Cloud /v1 backend, which exchanges the OIDC code and validates the
|
||||
# token as app `hanzo-cloud` (aud=hanzo-cloud) — so the front-end presents the
|
||||
# same app/client_id. Must match the cloud-api binding, not a console-only app.
|
||||
NEXT_PUBLIC_IAM_APP_NAME=hanzo-cloud
|
||||
NEXT_PUBLIC_IAM_ORG_NAME=hanzo
|
||||
NEXT_PUBLIC_IAM_CLIENT_ID=hanzo-cloud
|
||||
@@ -1,22 +0,0 @@
|
||||
# When adding additional environment variables, the schema in "/src/env.mjs"
|
||||
# should be updated accordingly.
|
||||
|
||||
# Prisma
|
||||
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env
|
||||
DIRECT_URL="postgresql://postgres:postgres@db:5432/postgres"
|
||||
DATABASE_URL="postgresql://postgres:postgres@db:5432/postgres"
|
||||
# Next Auth
|
||||
NEXTAUTH_SECRET="secret"
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
|
||||
# feature flag to enable experimental features locally
|
||||
LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES="false"
|
||||
SALT="salt"
|
||||
|
||||
# Redis
|
||||
REDIS_HOST="127.0.0.1"
|
||||
REDIS_PORT=6379
|
||||
REDIS_AUTH="myredissecret"
|
||||
|
||||
# openssl rand -hex 32 used only here
|
||||
ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
|
||||
@@ -1,258 +0,0 @@
|
||||
# More information: https://langfuse.com/docs/deployment/self-host
|
||||
|
||||
# When adding additional environment variables, the schema in "/src/env.mjs"
|
||||
# should be updated accordingly.
|
||||
|
||||
# Prisma
|
||||
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env
|
||||
# DATABASE_URL supports pooled connections, but then you need to set DIRECT_URL
|
||||
DATABASE_URL="postgresql://postgres:postgres@db:5432/postgres"
|
||||
# DIRECT_URL="postgresql://postgres:postgres@db:5432/postgres"
|
||||
# SHADOW_DATABASE_URL=
|
||||
# optional, set to true to disable automated database migrations on Docker start
|
||||
# LANGFUSE_AUTO_POSTGRES_MIGRATION_DISABLED=
|
||||
|
||||
# Next Auth
|
||||
# NEXTAUTH_URL does not need to be set when deploying on Vercel
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
|
||||
# For each of these, you can generate a new secret on the command line with:
|
||||
# openssl rand -base64 32
|
||||
NEXTAUTH_SECRET="secret" # https://next-auth.js.org/configuration/options#secret
|
||||
SALT="salt" # salt used to hash api keys
|
||||
|
||||
# API level encryption for sensitive data
|
||||
# Must be 256 bits, 64 string characters in hex format, generate via: openssl rand -hex 32
|
||||
ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000"
|
||||
|
||||
# Use CSP headers to enforce HTTPS, optional
|
||||
# LANGFUSE_CSP_ENFORCE_HTTPS="true"
|
||||
|
||||
# Configure base path for self-hosting, optional
|
||||
# Note: You need to build the docker image with the base path set and cannot use the pre-built docker image if you set this.
|
||||
# NEXT_PUBLIC_BASE_PATH="/app"
|
||||
|
||||
# Docker only, optional
|
||||
# PORT=3000
|
||||
# HOSTNAME=localhost
|
||||
|
||||
# Opentelemetry, optional
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318"
|
||||
OTEL_SERVICE_NAME="langfuse"
|
||||
|
||||
# Default role for users who sign up, optional, can be org or org+project
|
||||
# LANGFUSE_DEFAULT_ORG_ID=
|
||||
# LANGFUSE_DEFAULT_ORG_ROLE=
|
||||
# LANGFUSE_DEFAULT_PROJECT_ID=
|
||||
# LANGFUSE_DEFAULT_PROJECT_ROLE=
|
||||
|
||||
# Logging, optional
|
||||
# LANGFUSE_LOG_LEVEL=info
|
||||
# LANGFUSE_LOG_FORMAT=text
|
||||
|
||||
# Enable experimental features, optional
|
||||
# LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES=true
|
||||
|
||||
# Auth, optional configuration
|
||||
# AUTH_DOMAINS_WITH_SSO_ENFORCEMENT=domain1.com,domain2.com
|
||||
# AUTH_IGNORE_ACCOUNT_FIELDS=foo,bar
|
||||
# AUTH_DISABLE_USERNAME_PASSWORD=true
|
||||
# AUTH_DISABLE_SIGNUP=true
|
||||
# AUTH_SESSION_MAX_AGE=43200 # 30 days in minutes (default)
|
||||
|
||||
# SSO, each group is optional
|
||||
# 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
|
||||
# AUTH_GITHUB_ENTERPRISE_CLIENT_ID=
|
||||
# AUTH_GITHUB_ENTERPRISE_CLIENT_SECRET=
|
||||
# AUTH_GITHUB_ENTERPRISE_BASE_URL=
|
||||
# AUTH_GITHUB_ENTERPRISE_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_GITLAB_CLIENT_ID=
|
||||
# AUTH_GITLAB_CLIENT_SECRET=
|
||||
# AUTH_GITLAB_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_GITLAB_ISSUER=
|
||||
# AUTH_AZURE_AD_CLIENT_ID=
|
||||
# AUTH_AZURE_AD_CLIENT_SECRET=
|
||||
# AUTH_AZURE_AD_TENANT_ID=
|
||||
# AUTH_AZURE_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_OKTA_CLIENT_ID=
|
||||
# AUTH_OKTA_CLIENT_SECRET=
|
||||
# AUTH_OKTA_ISSUER=
|
||||
# AUTH_OKTA_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_AUTH0_CLIENT_ID=
|
||||
# AUTH_AUTH0_CLIENT_SECRET=
|
||||
# AUTH_AUTH0_ISSUER=
|
||||
# AUTH_AUTH0_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_COGNITO_CLIENT_ID=
|
||||
# AUTH_COGNITO_CLIENT_SECRET=
|
||||
# AUTH_COGNITO_ISSUER=
|
||||
# AUTH_COGNITO_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_KEYCLOAK_CLIENT_ID=
|
||||
# AUTH_KEYCLOAK_CLIENT_SECRET=
|
||||
# AUTH_KEYCLOAK_ISSUER=
|
||||
# AUTH_KEYCLOAK_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_CUSTOM_CLIENT_ID=
|
||||
# AUTH_CUSTOM_CLIENT_SECRET=
|
||||
# AUTH_CUSTOM_ISSUER=
|
||||
# AUTH_CUSTOM_NAME=
|
||||
# AUTH_CUSTOM_SCOPE="openid email profile" # optional
|
||||
# AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_CUSTOM_ID_TOKEN=false # optional, default is true
|
||||
|
||||
# Transactional email, optional
|
||||
# Defines the email address to use as the from address.
|
||||
# EMAIL_FROM_ADDRESS=
|
||||
# Defines the connection url for smtp server.
|
||||
# SMTP_CONNECTION_URL=
|
||||
|
||||
# S3 storage, optional, used for exports from the UI
|
||||
# S3_ENDPOINT=
|
||||
# S3_ACCESS_KEY_ID=
|
||||
# S3_SECRET_ACCESS_KEY=
|
||||
# S3_BUCKET_NAME=
|
||||
# S3_REGION=
|
||||
# BATCH_EXPORT_DOWNLOAD_LINK_EXPIRATION_HOURS=
|
||||
|
||||
# S3 storage for events, optional, used to persist all incoming events
|
||||
# LANGFUSE_S3_EVENT_UPLOAD_ENABLED="true"
|
||||
# LANGFUSE_S3_EVENT_UPLOAD_BUCKET=
|
||||
# Optional prefix to be used within the bucket. Must end with `/` if set
|
||||
# LANGFUSE_S3_EVENT_UPLOAD_PREFIX=events/
|
||||
# The following four options are optional and fallback to the normal SDK credential provider chain if omitted
|
||||
# See https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-credentials-node.html
|
||||
# LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT=
|
||||
# LANGFUSE_S3_EVENT_UPLOAD_REGION=
|
||||
# LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID=
|
||||
# LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY=
|
||||
|
||||
# Exports are streamed to S3 in pages to avoid memory issues
|
||||
# The page size can be adjusted if needed to optimize performance
|
||||
# DB_EXPORT_PAGE_SIZE=1000
|
||||
|
||||
# Automated provisioning of default resources
|
||||
# LANGFUSE_INIT_ORG_ID=org-id
|
||||
# LANGFUSE_INIT_ORG_NAME=org-name
|
||||
# LANGFUSE_INIT_PROJECT_ID=project-id
|
||||
# LANGFUSE_INIT_PROJECT_NAME=project-name
|
||||
# LANGFUSE_INIT_PROJECT_PUBLIC_KEY=pk-1234567890
|
||||
# LANGFUSE_INIT_PROJECT_SECRET_KEY=sk-1234567890
|
||||
# LANGFUSE_INIT_USER_EMAIL=user@example.com
|
||||
# LANGFUSE_INIT_USER_NAME=User Name
|
||||
# LANGFUSE_INIT_USER_PASSWORD=password
|
||||
|
||||
|
||||
|
||||
### START Enterprise Edition Configuration
|
||||
|
||||
# Allowlisted users that can create new organizations, by default all users can create organizations
|
||||
# LANGFUSE_ALLOWED_ORGANIZATION_CREATORS=user1@langfuse.com,user2@langfuse.com
|
||||
|
||||
# UI Customization Options
|
||||
# LANGFUSE_UI_API_HOST=https://api.example.com
|
||||
# LANGFUSE_UI_DOCUMENTATION_HREF=https://docs.example.com
|
||||
# LANGFUSE_UI_SUPPORT_HREF=https://support.example.com
|
||||
# LANGFUSE_UI_FEEDBACK_HREF=https://feedback.example.com
|
||||
# LANGFUSE_UI_LOGO_LIGHT_MODE_HREF=https://static.langfuse.com/langfuse-dev/example-logo-light-mode.png
|
||||
# LANGFUSE_UI_LOGO_DARK_MODE_HREF=https://static.langfuse.com/langfuse-dev/example-logo-dark-mode.png
|
||||
# LANGFUSE_UI_DEFAULT_MODEL_ADAPTER=Anthropic # OpenAI, Anthropic, Azure
|
||||
# LANGFUSE_UI_DEFAULT_BASE_URL_OPENAI=https://api.openai.com/v1
|
||||
# LANGFUSE_UI_DEFAULT_BASE_URL_ANTHROPIC=https://api.anthropic.com
|
||||
# LANGFUSE_UI_DEFAULT_BASE_URL_AZURE_OPENAI=https://{instanceName}.openai.azure.com/openai/deployments
|
||||
|
||||
### END Enterprise Edition Configuration
|
||||
|
||||
|
||||
|
||||
### START Envs to be deprecated in Langfuse v3.0
|
||||
|
||||
# Disable the expensive analytics queries and related features
|
||||
# LANGFUSE_DISABLE_EXPENSIVE_POSTGRES_QUERIES="true"
|
||||
|
||||
### END Envs to be deprecated in Langfuse v3.0
|
||||
|
||||
|
||||
|
||||
|
||||
### START Langfuse Cloud Config
|
||||
# Used for Langfuse Cloud deployments
|
||||
# Not recommended for self-hosted deployments as these are NOT COVERED BY SEMANTIC VERSIONING
|
||||
|
||||
# NEXT_PUBLIC_LANGFUSE_CLOUD_REGION="US"
|
||||
# NEXTAUTH_COOKIE_DOMAIN=".langfuse.com"
|
||||
|
||||
# LANGFUSE_TEAM_SLACK_WEBHOOK=
|
||||
# LANGFUSE_NEW_USER_SIGNUP_WEBHOOK=
|
||||
|
||||
# Posthog (optional for analytics of web ui)
|
||||
# NEXT_PUBLIC_POSTHOG_HOST=
|
||||
# NEXT_PUBLIC_POSTHOG_KEY=
|
||||
|
||||
# Sentry
|
||||
# NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE
|
||||
# NEXT_PUBLIC_SENTRY_DSN=
|
||||
# NEXT_SENTRY_ORG=
|
||||
# NEXT_SENTRY_PROJECT=
|
||||
# SENTRY_AUTH_TOKEN=
|
||||
# SENTRY_CSP_REPORT_URI=
|
||||
|
||||
|
||||
# Cloudflare Turnstile
|
||||
# NEXT_PUBLIC_TURNSTILE_SITE_KEY=
|
||||
# TURNSTILE_SECRET_KEY=
|
||||
|
||||
# Demo project that users can use to try the platform
|
||||
# NEXT_PUBLIC_DEMO_ORG_ID=
|
||||
# NEXT_PUBLIC_DEMO_PROJECT_ID=
|
||||
|
||||
# Crisp chat
|
||||
# NEXT_PUBLIC_CRISP_WEBSITE_ID=
|
||||
|
||||
# Admin API
|
||||
# ADMIN_API_KEY=
|
||||
|
||||
# Redis
|
||||
# REDIS_HOST=
|
||||
# REDIS_PORT=
|
||||
# REDIS_AUTH=
|
||||
# REDIS_CONNECTION_STRING=
|
||||
# REDIS_ENABLE_AUTO_PIPELINING=
|
||||
|
||||
# Cache configuration
|
||||
# LANGFUSE_CACHE_API_KEY_ENABLED=
|
||||
# LANGFUSE_CACHE_API_KEY_TTL_SECONDS=
|
||||
# LANGFUSE_CACHE_PROMPT_ENABLED=
|
||||
# LANGFUSE_CACHE_PROMPT_TTL_SECONDS=
|
||||
|
||||
# Rate limiting
|
||||
# LANGFUSE_RATE_LIMITS_ENABLED=
|
||||
|
||||
# Stripe
|
||||
# STRIPE_SECRET_KEY=
|
||||
# STRIPE_WEBHOOK_SIGNING_SECRET=
|
||||
|
||||
### END Langfuse Cloud Config
|
||||
|
||||
|
||||
## START Langfuse V3 Ingestion
|
||||
|
||||
# Clickhouse
|
||||
# CLICKHOUSE_MIGRATION_URL=
|
||||
# CLICKHOUSE_URL=
|
||||
# CLICKHOUSE_USER=
|
||||
# CLICKHOUSE_PASSWORD=
|
||||
|
||||
# Ingestion
|
||||
# LANGFUSE_INGESTION_QUEUE_DELAY_MS=
|
||||
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_BATCH_SIZE=
|
||||
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS=
|
||||
# LANGFUSE_INGESTION_CLICKHOUSE_MAX_ATTEMPTS=
|
||||
# LANGFUSE_LEGACY_INGESTION_WORKER_CONCURRENCY=
|
||||
# LANGFUSE_ASYNC_INGESTION_PROCESSING="true"
|
||||
# QUEUE_CONSUMER_LEGACY_INGESTION_QUEUE_IS_ENABLED="true"
|
||||
|
||||
## END Langfuse V3 Ingestion
|
||||
@@ -1,4 +0,0 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
build
|
||||
coverage
|
||||
@@ -1,2 +0,0 @@
|
||||
# Currently inactive
|
||||
# * @langfuse/maintainers
|
||||
@@ -1,11 +0,0 @@
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the feature or potential improvement
|
||||
description: Please describe the change as clear and concise as possible. Remember to add context as to why you believe this is needed.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Add any other information related to the change here. If your idea is related to any issues or discussions, link them here.
|
||||
@@ -1,35 +0,0 @@
|
||||
name: 🐞 Bug Report
|
||||
description: Create a bug report to help us improve
|
||||
title: "bug: "
|
||||
labels: ["🐞❔ unconfirmed bug"]
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the bug
|
||||
description: A clear and concise description of the bug, as well as what you expected to happen when encountering it.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: To reproduce
|
||||
description: Describe how to reproduce your bug. Please provide detailed steps, code snippets, reproduction repos etc.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: SDK and container versions
|
||||
description: If you're experiencing an issue with an integration or SDK, please ensure you're using the latest version. If you're self-hosting Langfuse, check that you're running the most recent version. If updating isn't an option, please provide the specific versions you're currently using.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Add any other information related to the bug here, screenshots if applicable.
|
||||
- type: dropdown
|
||||
id: contribute
|
||||
attributes:
|
||||
label: Are you interested to contribute a fix for this bug?
|
||||
description: If this is a confirmed bug, the maintainers are happy to support with guidance and review.
|
||||
options:
|
||||
- "No"
|
||||
- "Yes"
|
||||
validations:
|
||||
required: true
|
||||
@@ -1,7 +0,0 @@
|
||||
contact_links:
|
||||
- name: 💡 Feature Request
|
||||
url: https://github.com/orgs/langfuse/discussions/new?category=ideas
|
||||
about: Suggest any ideas you have using our discussion forums.
|
||||
- name: 🤗 Get Help
|
||||
url: https://github.com/orgs/langfuse/discussions/new?category=support
|
||||
about: If you can’t get something to work the way you expect, open a question in our discussion forums.
|
||||
@@ -1,36 +0,0 @@
|
||||
## What does this PR do?
|
||||
|
||||
<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->
|
||||
|
||||
Fixes # (issue)
|
||||
|
||||
<!-- Please provide a loom video for visual changes to speed up reviews
|
||||
Loom Video: https://www.loom.com/
|
||||
-->
|
||||
|
||||
## Type of change
|
||||
|
||||
<!-- Please delete bullets that are not relevant. -->
|
||||
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] Chore (refactoring code, technical debt, workflow improvements)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] Refactor (does not change functionality, e.g. code style improvements, linting)
|
||||
- [ ] This change requires a documentation update
|
||||
|
||||
## Mandatory Tasks
|
||||
|
||||
- [ ] Make sure you have self-reviewed the code. A decent size PR without self-review might be rejected.
|
||||
|
||||
## Checklist
|
||||
|
||||
<!-- Remove bullet points below that don't apply to you -->
|
||||
|
||||
- I haven't read the [contributing guide](https://github.com/langfuse/langfuse/blob/main/CONTRIBUTING.md)
|
||||
- My code doesn't follow the style guidelines of this project (`npm run prettier`)
|
||||
- I haven't commented my code, particularly in hard-to-understand areas
|
||||
- I haven't checked if my PR needs changes to the documentation
|
||||
- I haven't checked if my changes generate no new warnings (`npm run lint`)
|
||||
- I haven't added tests that prove my fix is effective or that my feature works
|
||||
- I haven't checked if new and existing unit tests pass locally with my changes
|
||||
@@ -1,48 +0,0 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
directory: "/" # Location of package manifests
|
||||
rebase-strategy: "disabled" # use dependabot-rebase-stale
|
||||
open-pull-requests-limit: 10
|
||||
schedule:
|
||||
interval: "daily"
|
||||
versioning-strategy: "increase"
|
||||
commit-message:
|
||||
prefix: chore
|
||||
prefix-development: chore
|
||||
include: scope
|
||||
ignore:
|
||||
- dependency-name: "@types/node"
|
||||
- dependency-name: "@trpc/*"
|
||||
groups:
|
||||
prisma:
|
||||
patterns:
|
||||
- "prisma"
|
||||
- "@prisma/client"
|
||||
next:
|
||||
patterns:
|
||||
- "eslint-config-next"
|
||||
- "next"
|
||||
lodash:
|
||||
patterns:
|
||||
- "lodash"
|
||||
- "@types/lodash"
|
||||
express:
|
||||
patterns:
|
||||
- "express"
|
||||
- "@types/express"
|
||||
observability:
|
||||
patterns:
|
||||
- "dd-trace"
|
||||
- "@opentelemetry/*"
|
||||
- "@appsignal/opentelemetry-instrumentation-bullmq"
|
||||
- "@prisma/instrumentation"
|
||||
- "@sentry/*"
|
||||
radix-ui:
|
||||
patterns:
|
||||
- "@radix-ui/*"
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="640" viewBox="0 0 1280 640" role="img" aria-label="console2">
|
||||
<rect width="1280" height="640" fill="#0A0A0A"/>
|
||||
<svg x="96" y="215" width="210" height="210" viewBox="0 0 67 67"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#fff"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#fff"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#fff"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#fff"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#fff"/></svg>
|
||||
<text x="378" y="276" font-family="Inter,system-ui,-apple-system,sans-serif" font-size="78" font-weight="800" letter-spacing="-2" fill="#ffffff">console2</text>
|
||||
|
||||
<rect x="378" y="338" width="806" height="3" rx="1.5" fill="#ffffff" opacity=".9"/>
|
||||
<text x="378" y="390" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">github.com/hanzoai</text>
|
||||
<text x="1184" y="390" text-anchor="end" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">hanzo.ai</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -1,75 +0,0 @@
|
||||
name: WorkflowCall - Deploy to ECS Service
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
environment:
|
||||
type: string
|
||||
description: Deployment environment
|
||||
required: true
|
||||
service:
|
||||
type: string
|
||||
description: Name of the service to be deployed, e.g. web-ingestion, web, or worker.
|
||||
required: true
|
||||
jobs:
|
||||
ecs-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
environment: ${{ inputs.environment }}
|
||||
steps:
|
||||
- name: Get app name
|
||||
uses: winterjung/split@v2
|
||||
id: split
|
||||
with:
|
||||
msg: ${{ inputs.service }}
|
||||
separator: "-"
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Authenticate with AWS
|
||||
# GitHub/AWS recommend to use OIDC here: https://github.com/aws-actions/configure-aws-credentials?tab=readme-ov-file#oidc
|
||||
# Probably more painful to configure, but would remove all long-lived credentials.
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
- name: Login to AWS ECR
|
||||
id: login-ecr
|
||||
uses: aws-actions/amazon-ecr-login@v2
|
||||
- name: Build, tag, and push Docker image
|
||||
env:
|
||||
REGISTRY: ${{ steps.login-ecr.outputs.registry }}
|
||||
REPOSITORY: ${{ steps.split.outputs._0 }}
|
||||
IMAGE_TAG: ${{ github.sha }}
|
||||
run: |
|
||||
docker build \
|
||||
-t $REGISTRY/$REPOSITORY:$IMAGE_TAG \
|
||||
-f ./${{ steps.split.outputs._0 }}/Dockerfile \
|
||||
--build-arg NEXT_PUBLIC_LANGFUSE_CLOUD_REGION=${{ vars.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION }} \
|
||||
--build-arg NEXT_LANGFUSE_TRACING_SAMPLE_RATE=${{ vars.NEXT_LANGFUSE_TRACING_SAMPLE_RATE }} \
|
||||
--build-arg NEXT_PUBLIC_SENTRY_ENVIRONMENT=${{ vars.NEXT_PUBLIC_SENTRY_ENVIRONMENT }} \
|
||||
--build-arg NEXT_PUBLIC_DEMO_ORG_ID=${{ vars.NEXT_PUBLIC_DEMO_ORG_ID }} \
|
||||
--build-arg NEXT_PUBLIC_DEMO_PROJECT_ID=${{ vars.NEXT_PUBLIC_DEMO_PROJECT_ID }} \
|
||||
--build-arg NEXT_PUBLIC_SENTRY_DSN=${{ vars.NEXT_PUBLIC_SENTRY_DSN }} \
|
||||
--build-arg NEXT_PUBLIC_BUILD_ID=${{ github.sha }} \
|
||||
--build-arg NEXT_PUBLIC_POSTHOG_KEY=${{ vars.NEXT_PUBLIC_POSTHOG_KEY }} \
|
||||
--build-arg NEXT_PUBLIC_POSTHOG_HOST=${{ vars.NEXT_PUBLIC_POSTHOG_HOST }} \
|
||||
--build-arg NEXT_PUBLIC_CRISP_WEBSITE_ID=${{ vars.NEXT_PUBLIC_CRISP_WEBSITE_ID }} \
|
||||
--build-arg SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} \
|
||||
--build-arg SENTRY_ORG=${{ vars.SENTRY_ORG }} \
|
||||
--build-arg SENTRY_PROJECT=${{ vars.SENTRY_PROJECT }} \
|
||||
.
|
||||
docker push $REGISTRY/$REPOSITORY:$IMAGE_TAG
|
||||
|
||||
- name: Render AWS ECS Task Definition
|
||||
id: render-task-definition
|
||||
uses: aws-actions/amazon-ecs-render-task-definition@v1
|
||||
with:
|
||||
container-name: ${{ inputs.service }}
|
||||
image: ${{ steps.login-ecr.outputs.registry }}/${{ steps.split.outputs._0 }}:${{ github.sha }}
|
||||
task-definition-family: ${{ inputs.environment }}-${{ inputs.service }}
|
||||
- name: Update AWS ECS Service
|
||||
uses: aws-actions/amazon-ecs-deploy-task-definition@v2
|
||||
with:
|
||||
task-definition: ${{ steps.render-task-definition.outputs.task-definition }}
|
||||
service: ${{ inputs.environment }}-${{ inputs.service }}
|
||||
cluster: ${{ inputs.environment }}-cluster
|
||||
wait-for-service-stability: true
|
||||
@@ -0,0 +1,78 @@
|
||||
name: Build Docker Image
|
||||
# Builds + pushes ghcr.io/hanzoai/console2 on the self-hosted ARC runner. ONE
|
||||
# brand-agnostic image serves every brand: console2 resolves the brand at RUNTIME
|
||||
# from the request hostname (console.hanzo.ai → hanzo, console.lux.cloud → lux,
|
||||
# console.zoo.cloud → zoo; src/config/index.ts), and /v1 is same-origin per host.
|
||||
# So NO NEXT_PUBLIC_* are baked — baking them would pin the image to one brand.
|
||||
# Tags: SEMVER ONLY (no sha, no :latest) — a `v*` git tag publishes that exact
|
||||
# version; a main push publishes `v<package.json version>` (bump to release).
|
||||
#
|
||||
# Build muscle: RAW `docker buildx build` on the host builder — the canonical
|
||||
# hanzoai/ci pattern. We deliberately do NOT use docker/setup-buildx-action +
|
||||
# docker/build-push-action: that pair spins up an EPHEMERAL buildkit container
|
||||
# that cannot see the host's image cache, so it re-pulled `node:22-alpine` from
|
||||
# Docker Hub every run and tripped the unauthenticated 429 pull-rate limit (and
|
||||
# its build-summary artifact upload hit the Actions storage quota). The host
|
||||
# builder reuses the cached base layer instead — no re-pull, no artifact upload.
|
||||
# The Dockerfile uses the ECR Public Docker-library mirror for the Node base image
|
||||
# so a cold runner does not depend on Docker Hub's unauthenticated pull budget.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: docker-image-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: [hanzo-build-linux-amd64]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: resolve semver tag
|
||||
id: ver
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||
echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
# node is NOT on the ARC runner PATH — resolve the version with grep/sed
|
||||
# (a missing `node` silently produced the tag `:v` and a 404 deploy).
|
||||
ver=$(grep -m1 '"version"' package.json | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
|
||||
[ -n "$ver" ] || { echo "could not resolve version from package.json"; exit 1; }
|
||||
echo "tag=v${ver}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Log in to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }}
|
||||
- name: Ensure base image (reuse host cache)
|
||||
# No-op on a warm runner (the host builder reuses the cached base). Only a
|
||||
# cold runner pulls. Use the same ECR Public Docker-library mirror as the
|
||||
# Dockerfile to avoid Docker Hub's unauthenticated pull-rate limits.
|
||||
run: |
|
||||
set -u
|
||||
base=public.ecr.aws/docker/library/node:24-alpine
|
||||
if docker image inspect "$base" >/dev/null 2>&1; then
|
||||
echo "base $base already cached on runner"; exit 0
|
||||
fi
|
||||
for i in 1 2 3 4 5; do
|
||||
if docker pull "$base"; then exit 0; fi
|
||||
echo "pull failed (attempt $i/5) — backing off"; sleep $((i * 30))
|
||||
done
|
||||
echo "could not pull $base after retries"; exit 1
|
||||
- name: Build & push (host builder — reuses base cache, no artifact upload)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker buildx build \
|
||||
--platform linux/amd64 \
|
||||
--push \
|
||||
-t ghcr.io/hanzoai/console2:${{ steps.ver.outputs.tag }} \
|
||||
-f Dockerfile .
|
||||
@@ -1,69 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: ["*"]
|
||||
push:
|
||||
branches: ["main", "cloud"]
|
||||
|
||||
# You can leverage Vercel Remote Caching with Turbo to speed up your builds
|
||||
# @link https://turborepo.org/docs/core-concepts/remote-caching#remote-caching-on-vercel-builds
|
||||
env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
|
||||
jobs:
|
||||
build-lint:
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
|
||||
SHADOW_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
|
||||
DIRECT_URL: postgresql://postgres:postgres@localhost:5432/postgres
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Start containers
|
||||
run: docker compose -f "docker-compose.yml" up -d --build
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2.2.4
|
||||
|
||||
- name: Setup Node 18
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: |
|
||||
echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install deps (with cache)
|
||||
run: pnpm install
|
||||
|
||||
# Normally, this would be done as part of the turbo pipeline - however since the Expo app doesn't depend on `@acme/db` it doesn't care.
|
||||
# TODO: Free for all to find a better solution here.
|
||||
- name: Deploy db
|
||||
run: pnpm turbo db:deploy
|
||||
|
||||
- name: Generate Prisma Client
|
||||
run: pnpm turbo db:generate
|
||||
|
||||
- name: Build, lint and type-check
|
||||
run: pnpm turbo build lint type-check
|
||||
env:
|
||||
SKIP_ENV_VALIDATION: true
|
||||
|
||||
# FIXME: Add this back once we have an Expo SDK supporting React 18.2
|
||||
# - name: Check workspaces
|
||||
# run: pnpm manypkg check
|
||||
@@ -1,92 +0,0 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main", "production", "v*"]
|
||||
pull_request:
|
||||
branches: ["main", "production", "v*"]
|
||||
schedule:
|
||||
- cron: "31 13 * * 0"
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
# Runner size impacts CodeQL analysis time. To learn more, please see:
|
||||
# - https://gh.io/recommended-hardware-resources-for-running-codeql
|
||||
# - https://gh.io/supported-runners-and-hardware-resources
|
||||
# - https://gh.io/using-larger-runners (GitHub.com only)
|
||||
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
|
||||
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
|
||||
permissions:
|
||||
# required for all workflows
|
||||
security-events: write
|
||||
|
||||
# required to fetch internal or private CodeQL packs
|
||||
packages: read
|
||||
|
||||
# only required for workflows in private repositories
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: javascript-typescript
|
||||
build-mode: none
|
||||
# CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
|
||||
# Use `c-cpp` to analyze code written in C, C++ or both
|
||||
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
|
||||
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
|
||||
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
|
||||
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
|
||||
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
|
||||
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
# If the analyze step fails for one of the languages you are analyzing with
|
||||
# "We were unable to automatically build your code", modify the matrix above
|
||||
# to set the build mode to "manual" for that language. Then modify this step
|
||||
# to build your code.
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
- if: matrix.build-mode == 'manual'
|
||||
shell: bash
|
||||
run: |
|
||||
echo 'If you are using a "manual" build mode for one or more of the' \
|
||||
'languages you are analyzing, replace this with the commands to build' \
|
||||
'your code, for example:'
|
||||
echo ' make bootstrap'
|
||||
echo ' make release'
|
||||
exit 1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
name: Codespell
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
branches:
|
||||
- "**"
|
||||
merge_group:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
codespell:
|
||||
name: Check for spelling errors
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Codespell
|
||||
uses: codespell-project/actions-codespell@v2
|
||||
@@ -1,18 +0,0 @@
|
||||
name: Rebase Dependabot stale PRs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
rebase-dependabot:
|
||||
runs-on: ubuntu-latest
|
||||
environment: "protected branches"
|
||||
steps:
|
||||
- name: "Rebase open Dependabot PR"
|
||||
uses: orange-buffalo/dependabot-auto-rebase@v1
|
||||
with:
|
||||
api-token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
repository: ${{ github.repository }}
|
||||
@@ -1,105 +0,0 @@
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- production
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
service:
|
||||
description: 'Service to be deployed'
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- web
|
||||
- web-ingestion
|
||||
- worker
|
||||
required: true
|
||||
environment:
|
||||
description: 'Environment to deploy to'
|
||||
type: choice
|
||||
options:
|
||||
- staging
|
||||
- prod-eu
|
||||
- prod-us
|
||||
required: true
|
||||
|
||||
concurrency:
|
||||
# Support concurrent `push` and `workflow_dispatch`` actions
|
||||
group: deploy-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
name: Deploy to ECS
|
||||
jobs:
|
||||
affected-services:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
services: ${{ steps.affected-services.outputs.result }}
|
||||
steps:
|
||||
- name: Get affected services
|
||||
id: affected-services
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
if (context.eventName === "workflow_dispatch") {
|
||||
if (context.payload.inputs.service === "all") {
|
||||
return `["web", "web-ingestion", "worker"]`
|
||||
}
|
||||
return `["${context.payload.inputs.service}"]`
|
||||
}
|
||||
if (context.eventName === "push") {
|
||||
return `["web", "web-ingestion", "worker"]`
|
||||
}
|
||||
return "[]"
|
||||
result-encoding: string
|
||||
- name: Print services to build
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
services: ${{ steps.affected-services.outputs.result }}
|
||||
with:
|
||||
result-encoding: string
|
||||
script: |
|
||||
console.log('Services', `${process.env.services}` ?? 'n/a');
|
||||
|
||||
affected-environments:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
environments: ${{ steps.affected-environments.outputs.result }}
|
||||
steps:
|
||||
- name: Get affected environments
|
||||
id: affected-environments
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
if (context.eventName === "workflow_dispatch") {
|
||||
return `["${context.payload.inputs.environment}"]`
|
||||
}
|
||||
if (context.eventName === "push") {
|
||||
if (context.ref === "refs/heads/main") {
|
||||
return `["staging"]`
|
||||
}
|
||||
if (context.ref === "refs/heads/production") {
|
||||
return `["prod-eu", "prod-us"]`
|
||||
}
|
||||
}
|
||||
return "[]"
|
||||
result-encoding: string
|
||||
- name: Print environments to build
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
environments: ${{ steps.affected-environments.outputs.result }}
|
||||
with:
|
||||
result-encoding: string
|
||||
script: |
|
||||
console.log('Environments', `${process.env.environments}` ?? 'n/a');
|
||||
|
||||
ecs-deploy:
|
||||
uses: ./.github/workflows/_deploy_ecs_service.yml
|
||||
needs: [affected-services, affected-environments]
|
||||
secrets: inherit
|
||||
strategy:
|
||||
matrix:
|
||||
service: ${{ fromJson(needs.affected-services.outputs.services) }}
|
||||
environment: ${{ fromJson(needs.affected-environments.outputs.environments) }}
|
||||
with:
|
||||
service: ${{ matrix.service }}
|
||||
environment: ${{ matrix.environment }}
|
||||
@@ -1,49 +0,0 @@
|
||||
name: License Compliance Check
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
merge_group:
|
||||
pull_request:
|
||||
branches:
|
||||
- "main"
|
||||
|
||||
jobs:
|
||||
license_check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
|
||||
- name: Install license-checker
|
||||
run: npm install -g license-checker
|
||||
|
||||
- name: Install yui-lint
|
||||
run: npm install yui-lint
|
||||
|
||||
- name: Generate license-checker CSV file
|
||||
run: license-checker --production --csv > npm-license-checker.csv
|
||||
|
||||
- name: Check license-checker CSV file without headers
|
||||
id: license_check_report
|
||||
uses: pilosus/action-pip-license-checker@v2
|
||||
with:
|
||||
external: "npm-license-checker.csv"
|
||||
external-format: "csv"
|
||||
external-options: "{:skip-header true}"
|
||||
fail: "WeakCopyleft,StrongCopyleft,NetworkCopyleft"
|
||||
fails-only: true
|
||||
totals: true
|
||||
verbose: 1
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Echo error
|
||||
if: failure()
|
||||
run: echo "::error::${{ steps.license_check_report.outputs.report }}"
|
||||
- name: Delete license-checker CSV file
|
||||
run: rm npm-license-checker.csv
|
||||
@@ -1,489 +0,0 @@
|
||||
name: CI/CD
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
tags:
|
||||
- "v*"
|
||||
merge_group:
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9.5.0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "pnpm-lock.yaml"
|
||||
- name: install dependencies
|
||||
run: |
|
||||
pnpm i
|
||||
- name: Load default env
|
||||
run: |
|
||||
cp .env.dev.example .env
|
||||
- name: lint web
|
||||
run: pnpm run lint
|
||||
|
||||
test-docker-build:
|
||||
timeout-minutes: 20
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME_READ }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN_READ }}
|
||||
- name: Set NEXT_PUBLIC_BUILD_ID
|
||||
run: echo "NEXT_PUBLIC_BUILD_ID=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||
- 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: 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
|
||||
- name: Check worker health
|
||||
run: |
|
||||
timeout 10 bash -c 'until curl -f http://localhost:3030/api/health; do sleep 2; done'
|
||||
- name: Check server health
|
||||
run: |
|
||||
timeout 10 bash -c 'until curl -f http://localhost:3000/api/public/health; do sleep 2; done'
|
||||
|
||||
tests-web-sync:
|
||||
timeout-minutes: 20
|
||||
runs-on: ubuntu-latest
|
||||
name: tests-web-sync (node${{ matrix.node-version }}, pg${{ matrix.postgres-version }})
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20]
|
||||
postgres-version: [12, 15]
|
||||
steps:
|
||||
- name: Set Swap Space
|
||||
uses: pierotofy/set-swap-space@master
|
||||
with:
|
||||
swap-size-gb: 10
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install golang-migrate for Clickhouse migrations
|
||||
run: |
|
||||
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.2/migrate.linux-amd64.tar.gz | tar xvz
|
||||
sudo mv migrate /usr/bin/migrate
|
||||
which migrate
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9.5.0
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME_READ }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN_READ }}
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "pnpm-lock.yaml"
|
||||
- name: install dependencies
|
||||
run: |
|
||||
pnpm install
|
||||
- name: Load default env
|
||||
run: |
|
||||
cp .env.dev.example .env
|
||||
grep -v -e '^S3_BUCKET_NAME=' -e '^REDIS_HOST=' -e '^NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT=' .env.dev.legacy.example > .env
|
||||
- name: Run + migrate
|
||||
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: |
|
||||
pnpm run db:migrate
|
||||
pnpm --filter=shared ch:up
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
- name: Start Langfuse
|
||||
run: (pnpm run start&)
|
||||
env:
|
||||
LANGFUSE_INIT_ORG_ID: "seed-org-id"
|
||||
LANGFUSE_INIT_ORG_NAME: "Seed Org"
|
||||
LANGFUSE_INIT_PROJECT_ID: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a"
|
||||
LANGFUSE_INIT_PROJECT_NAME: "Seed Project"
|
||||
LANGFUSE_INIT_PROJECT_PUBLIC_KEY: "pk-lf-1234567890"
|
||||
LANGFUSE_INIT_PROJECT_SECRET_KEY: "sk-lf-1234567890"
|
||||
LANGFUSE_INIT_USER_EMAIL: "demo@langfuse.com"
|
||||
LANGFUSE_INIT_USER_NAME: "Demo User"
|
||||
LANGFUSE_INIT_USER_PASSWORD: "password"
|
||||
- name: run test-sync
|
||||
run: pnpm --filter=web run test-sync
|
||||
|
||||
tests-web-async:
|
||||
timeout-minutes: 20
|
||||
runs-on: ubuntu-latest
|
||||
name: tests-web-async (node${{ matrix.node-version }}, pg${{ matrix.postgres-version }}, mode${{ matrix.blob-provider }})
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20]
|
||||
postgres-version: [12, 15]
|
||||
blob-provider: ["", "-azure"]
|
||||
steps:
|
||||
- name: Set Swap Space
|
||||
uses: pierotofy/set-swap-space@master
|
||||
with:
|
||||
swap-size-gb: 10
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install golang-migrate for Clickhouse migrations
|
||||
run: |
|
||||
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.2/migrate.linux-amd64.tar.gz | tar xvz
|
||||
sudo mv migrate /usr/bin/migrate
|
||||
which migrate
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9.5.0
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME_READ }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN_READ }}
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "pnpm-lock.yaml"
|
||||
- name: install dependencies
|
||||
run: |
|
||||
pnpm install
|
||||
- name: Load default env
|
||||
run: |
|
||||
cp .env.dev${{ matrix.blob-provider }}.example .env
|
||||
grep -v -e '^S3_BUCKET_NAME=' -e '^REDIS_HOST=' -e '^NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT=' .env.dev${{ matrix.blob-provider }}.example > .env
|
||||
- name: Run + migrate
|
||||
run: |
|
||||
docker compose -f docker-compose.dev${{ matrix.blob-provider }}.yml up -d
|
||||
sleep 5 # Wait for PostgreSQL to accept connections
|
||||
docker compose ps
|
||||
env:
|
||||
POSTGRES_VERSION: ${{ matrix.postgres-version }}
|
||||
- name: Seed DB
|
||||
run: |
|
||||
pnpm run db:migrate
|
||||
pnpm --filter=shared ch:up
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
- name: Start Langfuse
|
||||
run: (pnpm run start&)
|
||||
env:
|
||||
LANGFUSE_INIT_ORG_ID: "seed-org-id"
|
||||
LANGFUSE_INIT_ORG_NAME: "Seed Org"
|
||||
LANGFUSE_INIT_PROJECT_ID: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a"
|
||||
LANGFUSE_INIT_PROJECT_NAME: "Seed Project"
|
||||
LANGFUSE_INIT_PROJECT_PUBLIC_KEY: "pk-lf-1234567890"
|
||||
LANGFUSE_INIT_PROJECT_SECRET_KEY: "sk-lf-1234567890"
|
||||
LANGFUSE_INIT_USER_EMAIL: "demo@langfuse.com"
|
||||
LANGFUSE_INIT_USER_NAME: "Demo User"
|
||||
LANGFUSE_INIT_USER_PASSWORD: "password"
|
||||
- name: run tests
|
||||
run: pnpm --filter=web run test
|
||||
|
||||
tests-worker:
|
||||
timeout-minutes: 20
|
||||
runs-on: ubuntu-latest
|
||||
name: tests-worker (node${{ matrix.node-version }}, pg${{ matrix.postgres-version }}, mode${{ matrix.blob-provider }})
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20]
|
||||
postgres-version: [12, 15]
|
||||
blob-provider: ["", "-azure"]
|
||||
steps:
|
||||
- name: Set Swap Space
|
||||
uses: pierotofy/set-swap-space@master
|
||||
with:
|
||||
swap-size-gb: 10
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9.5.0
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME_READ }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN_READ }}
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "pnpm-lock.yaml"
|
||||
- name: install dependencies
|
||||
run: |
|
||||
pnpm install
|
||||
- name: Install golang-migrate for Clickhouse migrations
|
||||
run: |
|
||||
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.2/migrate.linux-amd64.tar.gz | tar xvz
|
||||
sudo mv migrate /usr/bin/migrate
|
||||
which migrate
|
||||
- name: Load default env
|
||||
run: |
|
||||
cp .env.dev${{ matrix.blob-provider }}.example .env
|
||||
cp .env.dev${{ matrix.blob-provider }}.example web/.env
|
||||
cp .env.dev${{ matrix.blob-provider }}.example worker/.env
|
||||
- name: Run + migrate
|
||||
run: |
|
||||
docker compose -f docker-compose.dev${{ matrix.blob-provider }}.yml up -d
|
||||
sleep 5 # Wait for PostgreSQL to accept connections
|
||||
docker compose ps
|
||||
- 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
|
||||
- name: Seed DB
|
||||
run: |
|
||||
pnpm run db:migrate
|
||||
pnpm run db:seed
|
||||
pnpm run --filter=shared ch:up
|
||||
- name: Build
|
||||
run: pnpm --filter=worker... run build
|
||||
- name: run tests
|
||||
run: pnpm --filter=worker run test
|
||||
|
||||
e2e-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9.5.0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "pnpm-lock.yaml"
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME_READ }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN_READ }}
|
||||
- name: install dependencies
|
||||
run: |
|
||||
pnpm install
|
||||
- name: Load default env
|
||||
run: |
|
||||
cp .env.dev.example .env
|
||||
cp .env.dev.example web/.env
|
||||
- name: Run + migrate
|
||||
run: |
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
docker compose ps
|
||||
sleep 5 # Wait for PostgreSQL to accept connections
|
||||
- name: Seed DB
|
||||
run: |
|
||||
pnpm run db:migrate
|
||||
pnpm run db:seed
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
- name: Install playwright
|
||||
run: pnpm --filter=web exec playwright install --with-deps
|
||||
- name: Run e2e tests
|
||||
run: pnpm --filter=web run test:e2e
|
||||
|
||||
e2e-server-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME_READ }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN_READ }}
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9.5.0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "pnpm-lock.yaml"
|
||||
- name: install dependencies
|
||||
run: |
|
||||
pnpm install
|
||||
- name: Install golang-migrate for Clickhouse migrations
|
||||
run: |
|
||||
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.2/migrate.linux-amd64.tar.gz | tar xvz
|
||||
sudo mv migrate /usr/bin/migrate
|
||||
which migrate
|
||||
- name: Load default env
|
||||
run: |
|
||||
cp .env.dev.example .env
|
||||
echo "LANGFUSE_ASYNC_CLICKHOUSE_INGESTION_PROCESSING=true" >> .env
|
||||
echo "LANGFUSE_ASYNC_INGESTION_PROCESSING=true" >> .env
|
||||
echo "LANGFUSE_CACHE_API_KEY_ENABLED=true" >> .env
|
||||
echo "LANGFUSE_CACHE_PROMPT_ENABLED=true" >> .env
|
||||
- name: Run + migrate
|
||||
run: |
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
docker compose ps
|
||||
sleep 5 # Wait for PostgreSQL to accept connections
|
||||
- name: Ensure Docker dependencies are healthy
|
||||
run: |
|
||||
if docker-compose ps | grep "(unhealthy)"; then
|
||||
echo "One or more services are unhealthy"
|
||||
exit 1
|
||||
else
|
||||
echo "All services are healthy"
|
||||
fi
|
||||
- name: Seed DB
|
||||
run: |
|
||||
pnpm run db:migrate
|
||||
pnpm --filter=shared run ch:up
|
||||
pnpm run db:seed:examples
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
- name: Run server
|
||||
run: (pnpm run start&)
|
||||
- name: Check worker health
|
||||
run: |
|
||||
timeout 10 bash -c 'until curl -f http://localhost:3030/api/health; do sleep 2; done'
|
||||
- name: Check server health
|
||||
run: |
|
||||
timeout 10 bash -c 'until curl -f http://localhost:3000/api/public/health; do sleep 2; done'
|
||||
- name: Run e2e tests
|
||||
run: pnpm --filter=web run test:e2e:server
|
||||
|
||||
all-ci-passed:
|
||||
# This allows us to have a branch protection rule for tests and deploys with matrix
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
[
|
||||
lint,
|
||||
tests-web-sync,
|
||||
tests-worker,
|
||||
e2e-tests,
|
||||
test-docker-build,
|
||||
e2e-server-tests,
|
||||
tests-web-async,
|
||||
]
|
||||
if: always()
|
||||
steps:
|
||||
- name: Successful deploy
|
||||
if: ${{ !(contains(needs.*.result, 'failure')) }}
|
||||
run: exit 0
|
||||
working-directory: .
|
||||
- name: Failing deploy
|
||||
if: ${{ contains(needs.*.result, 'failure') }}
|
||||
run: exit 1
|
||||
working-directory: .
|
||||
|
||||
push-docker-image:
|
||||
needs: all-ci-passed
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/'))
|
||||
environment: "protected branches"
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9.5.0
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache-dependency-path: "pnpm-lock.yaml"
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set NEXT_PUBLIC_BUILD_ID
|
||||
run: echo "NEXT_PUBLIC_BUILD_ID=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver-opts: network=host
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta-web
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/langfuse/langfuse # GitHub
|
||||
langfuse/langfuse # Docker Hub
|
||||
flavor: |
|
||||
latest=false
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=sha
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v3') }}
|
||||
- name: Build and push Docker image (web)
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./web/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta-web.outputs.tags }}
|
||||
labels: ${{ steps.meta-web.outputs.labels }}
|
||||
platforms: |
|
||||
linux/amd64
|
||||
${{ startsWith(github.ref, 'refs/tags/') && 'linux/arm64' || '' }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta-worker
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/langfuse/langfuse-worker # GitHub
|
||||
langfuse/langfuse-worker # Docker Hub
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=sha
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
- name: Build and push Docker image (worker)
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./worker/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta-worker.outputs.tags }}
|
||||
labels: ${{ steps.meta-worker.outputs.labels }}
|
||||
platforms: |
|
||||
linux/amd64
|
||||
${{ startsWith(github.ref, 'refs/tags/') && 'linux/arm64' || '' }}
|
||||
@@ -1,19 +0,0 @@
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
# Pattern matched against refs/tags
|
||||
tags:
|
||||
- "v3.[0-9]+.[0-9]+" # Semantic version tags
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
environment: "protected branches"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main # Always checkout main even for tagged releases
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
- name: Push to production
|
||||
run: git push origin +main:production
|
||||
@@ -1,46 +0,0 @@
|
||||
name: Snyk Container
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- "**"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
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@v3
|
||||
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@v3
|
||||
with:
|
||||
sarif_file: snyk.sarif
|
||||
category: worker
|
||||
+12
-61
@@ -1,66 +1,17 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
dist/
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# database
|
||||
/prisma/db.sqlite
|
||||
/prisma/db.sqlite-journal
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
# build artifacts
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# production
|
||||
/build
|
||||
# env
|
||||
.env*.local
|
||||
|
||||
# idea
|
||||
.idea
|
||||
|
||||
# misc
|
||||
# editor / os
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
|
||||
.env*
|
||||
!.env.dev.example
|
||||
!.env.dev-azure.example
|
||||
!.env.local.example
|
||||
!.env.prod.example
|
||||
!.env.dev.legacy.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
/generated/typescript-server
|
||||
|
||||
# openapi spec that is copied during build
|
||||
/public/openapi*.yml
|
||||
|
||||
|
||||
# vscode
|
||||
.devcontainer
|
||||
|
||||
node_modules
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/.next/*
|
||||
**/.turbo/*
|
||||
.yarn
|
||||
.turbo
|
||||
.vscode/
|
||||
.idea/
|
||||
*.log
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,22 +0,0 @@
|
||||
#!/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
|
||||
Vendored
-10
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"unifiedjs.vscode-mdx",
|
||||
"yoavbls.pretty-ts-errors",
|
||||
"Prisma.prisma"
|
||||
]
|
||||
}
|
||||
Vendored
-28
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Web and Worker: server side",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "pnpm run dev"
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug client-side",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"url": "http://localhost:3000"
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug full stack",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "npm run dev",
|
||||
"serverReadyAction": {
|
||||
"pattern": "- Local:.+(https?://.+)",
|
||||
"uriFormat": "%s",
|
||||
"action": "debugWithChrome"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
-32
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.rulers": [100],
|
||||
"editor.tabSize": 2,
|
||||
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"astro",
|
||||
"typescript",
|
||||
"typescriptreact"
|
||||
],
|
||||
|
||||
"eslint.rules.customizations": [{ "rule": "*", "severity": "warn" }],
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"prettier.documentSelectors": [
|
||||
"**/*.{cjs,mjs,ts,tsx,astro,md,mdx,json,yaml,yml}"
|
||||
],
|
||||
"prettier.trailingComma": "all",
|
||||
"mdx.experimentalLanguageServer": true,
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
"docwriter.style": "JSDoc",
|
||||
"[prisma]": {
|
||||
"editor.defaultFormatter": "Prisma.prisma"
|
||||
},
|
||||
"eslint.lintTask.enable": true,
|
||||
"eslint.workingDirectories": ["./web", "./worker"]
|
||||
}
|
||||
-453
@@ -1,453 +0,0 @@
|
||||

|
||||
|
||||
# Contributing to Langfuse
|
||||
|
||||
First off, thanks for taking the time to contribute! ❤️
|
||||
|
||||
The best ways to contribute to Langfuse:
|
||||
|
||||
- Submit and vote on [Ideas](https://github.com/orgs/langfuse/discussions/categories/ideas)
|
||||
- Create and comment on [Issues](https://github.com/langfuse/langfuse/issues)
|
||||
- Open a PR.
|
||||
|
||||
We welcome contributions through GitHub pull requests. This document outlines our conventions regarding development workflow, commit message formatting, contact points, and other resources. Our goal is to simplify the process and ensure that your contributions are easily accepted.
|
||||
|
||||
We gratefully welcome improvements to documentation ([docs repo](https://github.com/langfuse/langfuse-docs)), the core application (this repo) and the SDKs ([Python](https://github.com/langfuse/langfuse-python), [JS](https://github.com/langfuse/langfuse-js)).
|
||||
|
||||
The maintainers are available on [Discord](https://langfuse.com/discord) in case you have any questions.
|
||||
|
||||
> And if you like the project, but just don't have time to contribute code, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about:
|
||||
>
|
||||
> - Star the project;
|
||||
> - Tweet about it;
|
||||
> - Refer to this project in your project's readme;
|
||||
> - Submit and vote on [Ideas](https://github.com/orgs/langfuse/discussions/categories/ideas);
|
||||
> - Create and comment on [Issues](https://github.com/langfuse/langfuse/issues);
|
||||
> - Mention the project at local meetups and tell your friends/colleagues.
|
||||
|
||||
## Making a change
|
||||
|
||||
_Before making any significant changes, please [open an issue](https://github.com/langfuse/langfuse/issues)._ Discussing your proposed changes ahead of time will make the contribution process smooth for everyone. Large changes that were not discussed in an issue may be rejected.
|
||||
|
||||
Once we've discussed your changes and you've got your code ready, make sure that tests are passing and open your pull request.
|
||||
|
||||
A good first step is to search for open [issues](https://github.com/langfuse/langfuse/issues). Issues are labeled, and some good issues to start with are labeled: [good first issue](https://github.com/langfuse/langfuse/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22).
|
||||
|
||||
## Project Overview
|
||||
|
||||
### Technologies we use
|
||||
|
||||
- Application (this repository)
|
||||
- NextJS 14, pages router
|
||||
- NextAuth.js / Auth.js
|
||||
- tRPC: Frontend APIs
|
||||
- Prisma ORM
|
||||
- Zod
|
||||
- Tailwind CSS
|
||||
- shadcn/ui tailwind components (using Radix and tanstack)
|
||||
- Fern: generate OpenAPI spec and Pydantic models
|
||||
- JS SDK ([langfuse/langfuse-js](https://github.com/langfuse/langfuse-js))
|
||||
- openapi-typescript to generated types based on OpenAPI spec
|
||||
- Python SDK ([langfuse/langfuse-python](https://github.com/langfuse/langfuse-python))
|
||||
- Pydantic for input validation, models generated by fern
|
||||
|
||||
### Architecture Overview
|
||||
|
||||
**Langfuse v2**
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph s4["Clients"]
|
||||
subgraph s2["langfuse/langfuse-python"]
|
||||
Python["Python low-level SDK"]
|
||||
Decorator["observe() decorator"] -->|extends| Python
|
||||
OAI["OpenAI drop-in replacement"] -->|extends| Python
|
||||
Llamaindex["LlamaIndex Integration"] -->|extends| Python
|
||||
LCPYTHON["Langchain Python Integration"] -->|extends| Python
|
||||
Langflow -->|uses| LCPYTHON
|
||||
LiteLLM -->|uses| Python
|
||||
end
|
||||
subgraph s3["langfuse/langfuse-js"]
|
||||
JS["JS SDK"]
|
||||
LCJS["Langchain JS Integration"] -->|extends| JS
|
||||
Flowise -->|uses| LCJS
|
||||
end
|
||||
end
|
||||
|
||||
DB[Postgres Database]
|
||||
Redis[Redis]
|
||||
|
||||
subgraph s1["Application (langfuse/langfuse/web)"]
|
||||
API[Public HTTP API]
|
||||
G[TRPC API]
|
||||
I[NextAuth]
|
||||
H[React Frontend]
|
||||
Prisma[Prisma ORM]
|
||||
H --> G
|
||||
H --> I
|
||||
G --> I
|
||||
G --- Prisma
|
||||
API --- Prisma
|
||||
I --- Prisma
|
||||
end
|
||||
|
||||
Prisma --- DB
|
||||
JS --- API
|
||||
Python --- API
|
||||
```
|
||||
|
||||
**Langfuse v3 (work in progress, not released yet)**
|
||||
|
||||
> [!NOTE]
|
||||
> Infrastructure will change in Langfuse version 3.0. More in the [GitHub Discussions](https://github.com/orgs/langfuse/discussions/1902).
|
||||
> `langfuse/langfuse/worker` is under active development and not recommended for production use in Langfuse 2.x.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph s4["Clients"]
|
||||
subgraph s2["langfuse/langfuse-python"]
|
||||
Python["Python low-level SDK"]
|
||||
Decorator["observe() decorator"] -->|extends| Python
|
||||
OAI["OpenAI drop-in replacement"] -->|extends| Python
|
||||
Llamaindex["LlamaIndex Integration"] -->|extends| Python
|
||||
LCPYTHON["Langchain Python Integration"] -->|extends| Python
|
||||
Langflow -->|uses| LCPYTHON
|
||||
LiteLLM -->|uses| Python
|
||||
end
|
||||
subgraph s3["langfuse/langfuse-js"]
|
||||
JS["JS SDK"]
|
||||
LCJS["Langchain JS Integration"] -->|extends| JS
|
||||
Flowise -->|uses| LCJS
|
||||
end
|
||||
end
|
||||
|
||||
DB[Postgres Database]
|
||||
Redis[Redis Cache/Queue]
|
||||
Clickhouse[Clickhouse Database]
|
||||
|
||||
subgraph s1["Application (langfuse/langfuse/web)"]
|
||||
API[Public HTTP API]
|
||||
G[TRPC API]
|
||||
I[NextAuth]
|
||||
H[React Frontend]
|
||||
ORM
|
||||
H --> G
|
||||
H --> I
|
||||
G --> I
|
||||
G --- ORM
|
||||
API --- ORM
|
||||
I --- ORM
|
||||
end
|
||||
|
||||
subgraph s5["Application (langfuse/langfuse/worker)"]
|
||||
Worker
|
||||
end
|
||||
|
||||
Worker --- DB
|
||||
Worker --- Redis
|
||||
Worker --- Clickhouse
|
||||
|
||||
ORM --- DB
|
||||
ORM --- Redis
|
||||
ORM --- Clickhouse
|
||||
|
||||
JS --- API
|
||||
Python --- API
|
||||
```
|
||||
|
||||
### Network Overview
|
||||
|
||||
> [!NOTE]
|
||||
> This will change in Langfuse version 3.0. More in the [GitHub Discussions](https://github.com/orgs/langfuse/discussions/1902).
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Browser ---|Web UI & TRPC API| App
|
||||
Integrations/SDKs ---|Public HTTP API| App
|
||||
subgraph i1["Application Network"]
|
||||
App["Langfuse Application"]
|
||||
end
|
||||
subgraph i2["Database Network"]
|
||||
DB["Postgres Database"]
|
||||
end
|
||||
App --- DB
|
||||
```
|
||||
|
||||
### Database Overview
|
||||
|
||||
The diagram below may not show all relationships if the foreign key is not defined in the database schema. For instance, `trace_id` in the `observation` table is not defined as a foreign key to the `trace` table to allow unordered ingestion of these objects, but it is still a foreign key in the application code.
|
||||
|
||||
Full database schema: [packages/shared/prisma/schema.prisma](packages/shared/prisma/schema.prisma)
|
||||
|
||||
<img src="./packages/shared/prisma/database.svg">
|
||||
|
||||
## Repository Structure
|
||||
|
||||
We built a monorepo using [pnpm](https://pnpm.io/motivation) and [turbo](https://turbo.build/repo/docs) to manage the dependencies and build process. The monorepo contains the following packages:
|
||||
|
||||
- `web`: is the main application package providing Frontend and Backend APIs for Langfuse.
|
||||
- `worker` (no production yet): contains an application for asynchronous processing of tasks. This package is not yet used in production.
|
||||
- `packages`:
|
||||
- `shared`: contains shared code between the above packages.
|
||||
- `config-eslint`: contains eslint configurations which are shared between the above packages.
|
||||
- `config-typescript`: contains typescript configurations which are shared between the above packages.
|
||||
- `ee`: contains all enterprise features. See [EE README](ee/README.md) for more details.
|
||||
|
||||
## Development Setup
|
||||
|
||||
Requirements
|
||||
|
||||
- Node.js 20 as specified in the [.nvmrc](.nvmrc)
|
||||
- Pnpm v.9.5.0
|
||||
- Docker to run the database locally
|
||||
|
||||
**Note:** You can also simply run Langfuse in a **GitHub Codespace** via the provided devcontainer. To do this, click on the green "Code" button in the top right corner of the repository and select "Open with Codespaces".
|
||||
|
||||
**Steps**
|
||||
|
||||
1. Fork the repository and clone it locally
|
||||
2. Run the development database
|
||||
|
||||
```bash
|
||||
pnpm run infra:dev:up
|
||||
```
|
||||
|
||||
3. Create an env file
|
||||
|
||||
```bash
|
||||
cp .env.dev.example .env
|
||||
```
|
||||
|
||||
4. Install dependencies
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
5. Run the migrations
|
||||
|
||||
All database migrations and configs are in the `shared` package.
|
||||
|
||||
```bash
|
||||
pnpm --filter=shared run db:migrate
|
||||
|
||||
# Optional: seed the database
|
||||
# pnpm run db:seed
|
||||
# pnpm run db:seed:examples
|
||||
# pnpm --filter=shared run db:seed:load
|
||||
```
|
||||
|
||||
6. Start the development server
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
Packages are included in the monorepo according to the `pnpm-workspace.yaml` file. Each package maintains its own dependencies defined in the `package.json`. Internal dependencies can be added as well by adding them to the package dependencies: `"@langfuse/shared": "workspace:*"`.
|
||||
|
||||
- Executing commands
|
||||
|
||||
You can run commands in all packages at once. For example, to install all dependencies in all packages, you can execute:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm run dev
|
||||
pnpm --filter=web run dev # execute command only in one package
|
||||
```
|
||||
|
||||
In the root `package.json`, you can find scripts which are executed with turbo e.g. `turbo run dev`. These scripts are executed with the help of Turbo. Turbo executes the commands in all packages taking care of the correct order of execution. Task definitions can be found in the `turbo.config.js` file.
|
||||
|
||||
- Run migrations
|
||||
|
||||
To run migrations, you can execute the following command.
|
||||
|
||||
```bash
|
||||
pnpm run db:migrate -- --name <name of the migration>
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> If you frequently switch branches, use `pnpm run dx` instead of `pnpm run dev`. This command will install dependencies, reset the database (wipe and apply all migrations), and run the database seeder with example data before starting the development server.
|
||||
|
||||
> [!NOTE]
|
||||
> If you find yourself stuck and want to clean the repo, execute `pnpm run nuke`. It will remove all node_modules and build files.
|
||||
|
||||
## System behavior
|
||||
|
||||
### Ingestion API `(/public/api/ingestion)`
|
||||
|
||||
- the ingestion API takes different event types (creation and updates of traces, generations, spans, events)
|
||||
- The API loops through each event and:
|
||||
- validates the event
|
||||
- stores the event raw in the events table
|
||||
- calculates tokens for `generations`
|
||||
- matches models from the `models` table to model for `generations` events
|
||||
- upserts the event in the `traces` or `observations` table
|
||||
- returns a `207` HTTP status code with a list of errors if any event failed to be ingested
|
||||
|
||||
## Commit messages
|
||||
|
||||
On the main branch, we adhere to the best practices of [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/). All pull requests and branches are squash-merged to maintain a clean and readable history. This approach ensures the addition of a conventional commit message when merging contributions.
|
||||
|
||||
## Test the public API
|
||||
|
||||
The API is tested using Jest. With the development server running, you can run the tests with:
|
||||
|
||||
Run all
|
||||
|
||||
```bash
|
||||
npm run test
|
||||
```
|
||||
|
||||
Run interactively in watch mode
|
||||
|
||||
```bash
|
||||
npm run test:watch
|
||||
```
|
||||
|
||||
These tests are also run in CI.
|
||||
|
||||
## CI/CD
|
||||
|
||||
We use GitHub Actions for CI/CD, the configuration is in [`.github/workflows/pipeline.yml`](.github/workflows/pipeline.yml)
|
||||
|
||||
CI on `main` and `pull_request`
|
||||
|
||||
- Check Linting
|
||||
- E2E test of API using Jest
|
||||
- E2E tests of UI using Playwright
|
||||
|
||||
CD on `main`
|
||||
|
||||
- Publish Docker image to GitHub Packages if CI passes. Done on every push to `main` branch. Only released versions are tagged with `latest`.
|
||||
|
||||
## Staging environment
|
||||
|
||||
We run a staging environment at [https://staging.langfuse.com](https://staging.langfuse.com) that is automatically deployed on every push to `main` branch.
|
||||
|
||||
The same environment is also used for preview deployments of pull requests. Limitations:
|
||||
|
||||
- SSO is not available as dynamic domains are not supported by most SSO providers.
|
||||
- When making changes to the database, migrations to the staging database need to be applied manually by a maintainer. If you want to interactively test database changes in the staging environment, please reach out.
|
||||
|
||||
You can use the staging environment end-to-end with the Langfuse integrations or SDKs (host: `https://staging.langfuse.com`). However, please note that the staging environment is not intended for production use and may be reset at any time.
|
||||
|
||||
## Production environment
|
||||
|
||||
When a new release is tagged on the `main` branch (excluding prereleases), it triggers a production deployment. The deployment process consists of two steps:
|
||||
|
||||
1. The Docker image is published to GitHub Packages with the version number and `latest` tag.
|
||||
2. The deployment is carried out on Langfuse Cloud. This is done by force pushing the `main` branch to the `production` branch during every release, using the [`release.yml`](.github/workflows/release.yml) GitHub Action.
|
||||
|
||||
## Theming
|
||||
|
||||
At Langfuse, we utilize CSS variables to manage our theme settings across the platform.
|
||||
|
||||
Our approach leverages separate CSS variables for backgrounds (--background) and foregrounds (--foreground), fully adhering to the [shadcn/ui](https://ui.shadcn.com/docs/theming) color conventions. The background suffix can be omitted if the variable is used for the background color of the component. We recommend using HSL values for these colors to enhance consistency and customization. There is no need to manually handle dark mode styling with "dark:" prefixes, as next-themes automatically manages the theme switching.
|
||||
|
||||
Given the following CSS variables:
|
||||
|
||||
```
|
||||
--primary: 222.2 47.4% 11.2%; // e.g. background-color
|
||||
--primary-foreground: 210 40% 98%; // e.g. text-color
|
||||
```
|
||||
|
||||
The background color of the following component will be `hsl(var(--primary))` and the foreground color will be `hsl(var(--primary-foreground))`.
|
||||
|
||||
```
|
||||
<div class="bg-primary text-primary-foreground">Hello</div>
|
||||
```
|
||||
|
||||
### Color Variables
|
||||
|
||||
| Variable | Description | Examples |
|
||||
| ------------------------ | ------------------------------------------------------------------ | -------------------------------- |
|
||||
| --background | Background color | Default background color of body |
|
||||
| --foreground | Foreground color | Default text color of body |
|
||||
| --muted | Muted background color | TabsList, Skeleton and Switch |
|
||||
| --muted-foreground | Muted foreground color | |
|
||||
| --popover | Popover background color | DropdownMenu, HoverCard, Popover |
|
||||
| --popover-foreground | Popover foreground color | |
|
||||
| --card | Card background color | Card |
|
||||
| --card-foreground | Card foreground color | |
|
||||
| --border | Border color | Default border color |
|
||||
| --input | Input field border color | Input, Select, Textarea |
|
||||
| --primary | Primary button background colors | Button variant="primary" |
|
||||
| --primary-foreground | Primary button foreground color | |
|
||||
| --secondary | Secondary button background color | Button variant="secondary" |
|
||||
| --secondary-foreground | Secondary button foreground color | |
|
||||
| --accent | Used for accents such as hover effects | DropdownMenuItem, SelectItem |
|
||||
| --accent-foreground | Used for texts on hover effects | DropdownMenuItem, SelectItem |
|
||||
| --destructive | Destructive action color for background | Button variant="destructive" |
|
||||
| --destructive-foreground | Destructive action color for text | |
|
||||
| --ring | Focus ring color | MultiSelect |
|
||||
| --primary-accent | Primary accent color used for branding | Layout |
|
||||
| --hover-primary-accent | Primary accent color used for hover effects for links | SignIn and AuthCloudRegionSwitch |
|
||||
| --muted-green | Muted green for Event label | ObservationTree |
|
||||
| --muted-orange | Muted orange for Generation label | ObservationTree |
|
||||
| --muted-blue | Muted blue for Span label | ObservationTree |
|
||||
| --muted-gray | Muted gray for disabled status badges | StatusBadge |
|
||||
| --accent-light-green | Light green accent for background of output and assistant messages | IOPreview, Generations, Traces |
|
||||
| --accent-dark-green | Dark green accent for border of output and assistant messages | CodeJsonViewer and IOPReview |
|
||||
| --light-red | Light red for error background | level-color and StatusBadge |
|
||||
| --dark-red | Dark red for error text and error badge dot color | level-color and ErrorPage |
|
||||
| --light-yellow | Light yellow for warning background | LevelColor |
|
||||
| --dark-yellow | Dark yellow for warning text | LevelColor |
|
||||
| --light-green | Light green for success status badge background | StatusBadge |
|
||||
| --dark-green | Dark green for success status badge text and dot | StatusBadge |
|
||||
| --light-blue | Light blue for background of Staging label | LangfuseLogo |
|
||||
| --dark-blue | Dark blue for text and border of Staging label | LangfuseLogo |
|
||||
| --accent-light-blue | Light blue accent for table link hover effect | TableLink |
|
||||
| --accent-dark-blue | Dark blue accent for table link text | TableLink |
|
||||
|
||||
### Adding New Colors
|
||||
|
||||
1. Global Definitions: Add new CSS variable definitions in the global.css file.
|
||||
2. Tailwind Configuration: Reflect these new colors in the tailwind.config.js to maintain alignment with Tailwind's utility classes.
|
||||
|
||||
By following these guidelines, you can ensure that any contributions to our theme are consistent, maintainable, and aligned with our design system.
|
||||
|
||||
## 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.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
op run --env-file="./.env" -- pnpm --filter=shared run db:deploy
|
||||
```
|
||||
|
||||
### Editing default models and prices
|
||||
|
||||
You can update the default AI models and prices by adding or updating an entry in `worker/src/constants/default-model-prices.json`.
|
||||
|
||||
Please note that
|
||||
|
||||
- prices are in USD
|
||||
- the list is ordered by ID, so make sure to keep this order
|
||||
- the `updated_at` field must be updated with the current date in ISO 8601 format. Otherwise, the change will be ignored.
|
||||
|
||||
### Transition period until V3 release
|
||||
|
||||
Until the V3 release, both the JSON record must be updated **and** a migration must be created to continue supporting self-hosted users. Note that the migration must updated both the `models` as well as the `prices` table accordingly.
|
||||
|
||||
## License
|
||||
|
||||
Langfuse is MIT licensed, except for `ee/` folder. See [LICENSE](LICENSE) and [docs](https://langfuse.com/docs/open-source) for more details.
|
||||
|
||||
When contributing to the Langfuse codebase, you need to agree to the [Contributor License Agreement](https://cla-assistant.io/langfuse/langfuse). You only need to do this once and the CLA bot will remind you if you haven't signed it yet.
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
# console2 — Hanzo Cloud Console (Next.js 15 + @hanzo/gui). BSD-3-Clause.
|
||||
# NEXT_PUBLIC_* are inlined at build time (browser config), so they are build args.
|
||||
FROM public.ecr.aws/docker/library/node:24-alpine AS build
|
||||
WORKDIR /app
|
||||
# Copy ALL source FIRST, then install — order matters under Kaniko --single-snapshot:
|
||||
# a `COPY` that FOLLOWS `RUN npm install` in the same stage drops the RUN's freshly
|
||||
# created node_modules (the 'next not found' cause — the install's own `test -f next`
|
||||
# passed, then `COPY . .` wiped node_modules before the build RUN). Putting COPY
|
||||
# before install means node_modules is created by the LAST RUNs and nothing clobbers
|
||||
# it. (Layer-cache for deps is moot here — the on-cluster build runs --cache=false.)
|
||||
COPY . .
|
||||
# public/ may be empty (git doesn't track empty dirs) — ensure it exists for the runner COPY.
|
||||
RUN mkdir -p public
|
||||
# npm install (not ci): @hanzo/gui pulls a react-native dep tree whose platform/
|
||||
# optional packages resolve differently across npm versions, so a lockfile generated
|
||||
# by one npm fails `npm ci` under another. install reconciles the tree for the
|
||||
# build platform; retry-hardened against registry throttling.
|
||||
RUN npm install --no-audit --no-fund --fetch-retries=5 --fetch-retry-mintimeout=20000 --fetch-timeout=120000
|
||||
# ONE brand-agnostic image: brand (IAM org/issuer/app + wordmark) is resolved at
|
||||
# RUNTIME from the request hostname (src/config/index.ts), and /v1 is same-origin
|
||||
# per host. Baking NEXT_PUBLIC_* here would inline a single brand and break that.
|
||||
# Next 15 + @hanzo/gui (large RN dep tree) overflows Node's default heap → OOMKill
|
||||
# (exit 137); cap the heap generously (chat uses 4096).
|
||||
ENV NEXT_TELEMETRY_DISABLED=1 NODE_OPTIONS=--max-old-space-size=6144
|
||||
RUN npm run build
|
||||
|
||||
FROM public.ecr.aws/docker/library/node:24-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=4000
|
||||
RUN addgroup -S app && adduser -S app -G app
|
||||
COPY --from=build /app/.next ./.next
|
||||
COPY --from=build /app/public ./public
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/package.json ./package.json
|
||||
COPY --from=build /app/next.config.mjs ./next.config.mjs
|
||||
USER app
|
||||
EXPOSE 4000
|
||||
CMD ["node", "node_modules/next/dist/bin/next", "start", "-p", "4000"]
|
||||
@@ -1,25 +1,42 @@
|
||||
Copyright (c) 2023--2024 Langfuse GmbH
|
||||
BSD 3-Clause License
|
||||
|
||||
Portions of this software are licensed as follows:
|
||||
Copyright (c) 2026-present, Hanzo AI, Inc.
|
||||
|
||||
- All content that resides under the "ee/" and/or "web/src/ee" directories of this repository, if these directories exist, is licensed under the license defined in "ee/LICENSE".
|
||||
- All third party components incorporated into the Finto Technologies Software are licensed under the original license provided by the owner of the applicable component.
|
||||
- Content outside of the above mentioned directories or restrictions above is available under the "MIT Expat" license as defined below.
|
||||
Portions of this software are derived from upstream code originally licensed under
|
||||
the MIT License, with the following copyright notices retained per its terms:
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
Copyright (c) 2020 Nate Wienert
|
||||
Copyright (c) 2015-present, Nicolas Gallagher.
|
||||
Copyright (c) 2015-present, Facebook, Inc.
|
||||
Copyright (c) 2021 Radix
|
||||
Copyright (c) 2017 Carmelo Pullara
|
||||
Copyright (c) 2018 Framer B.V.
|
||||
Copyright (c) 2022 WorkOS
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
All rights reserved.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its contributors
|
||||
may be used to endorse or promote products derived from this software
|
||||
without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
# console2 — Hanzo Cloud Console
|
||||
|
||||
Unified admin console for **Hanzo Cloud** and all cloud products. Our code,
|
||||
BSD-3-Clause, built on **@hanzo/gui** (the Tamagui-based cross-platform UI).
|
||||
NOT a Langfuse fork, NOT casibase — it is a clean client over the unified `/v1`
|
||||
backend (`hanzoai/cloud`, the casibase API at https://cloud.hanzo.ai/v1/*).
|
||||
|
||||
## Base: Next.js 15 (app router) + @hanzo/gui
|
||||
|
||||
The @hanzo/gui `expo-router` template was evaluated first and **rejected for a
|
||||
standalone repo**: it declares `workspace:*` dependencies (`hanzogui`,
|
||||
`@hanzogui/config`, `@hanzogui/babel-plugin`, …) that only resolve inside the gui
|
||||
bun monorepo — `npm install` of a copy fails with
|
||||
`EUNSUPPORTEDPROTOCOL "workspace:"`. It is also native-first with no real
|
||||
typecheck (`"test": "true"`), a poor fit for a data-heavy web admin.
|
||||
|
||||
So the base is **Next.js + @hanzo/gui (npm)**. Gui is consumed at **runtime**:
|
||||
Next's built-in `transpilePackages` transpiles the Gui ESM packages (discovered
|
||||
from `node_modules/@hanzogui`, not hardcoded) and `GuiProvider` injects CSS at
|
||||
runtime. Gui is designed to work this way — the optimizing compiler is an
|
||||
optimization, not a requirement.
|
||||
|
||||
The canonical `@hanzogui/next-plugin@7.3.0` is **broken on npm**: it depends on
|
||||
`hanzogui-loader@7.3.0` (unpublished — only `2.x`/`102.x` fork tags exist), and
|
||||
that fork renames the export the plugin imports (`GuiPlugin` → `HanzoguiPlugin`).
|
||||
Pinning the fork via overrides surfaces the rename at build time. So the plugin
|
||||
is unusable standalone; `transpilePackages` is the clean, supported path.
|
||||
|
||||
**v5 config uses `onlyShorthandStyleProps`** — components use Gui shorthand style
|
||||
props (`p`, `px`, `bg`, `items`, `justify`, `self`, `rounded`, `minH`, …), not
|
||||
longhands. With shorthands, `tsc --noEmit` (strict) passes clean and the build
|
||||
type-checks with no suppression.
|
||||
|
||||
**Next 15, not 14:** @hanzo/gui requires `react>=19`. Next 14 ships React 18 and
|
||||
cannot run React 19 (App Router server components are version-locked to the
|
||||
bundled React). Next 15.5.x is the current stable that natively supports React
|
||||
19 — so 15 is the correct, non-degrading choice. The task's "Next 14" is
|
||||
impossible without downgrading Gui or breaking the peer tree.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
app/ Next.js app router
|
||||
layout.tsx html shell, dark default, mounts <Provider>
|
||||
globals.css base resets (Gui CSS injected via plugin)
|
||||
signin/page.tsx sign-in (delegates to IAM)
|
||||
auth/callback/page.tsx OIDC callback -> /v1/signin -> session
|
||||
(dashboard)/
|
||||
layout.tsx AuthGate + DashboardShell
|
||||
page.tsx product overview cards
|
||||
[...slug]/page.tsx catch-all: resolves a module+route from the registry
|
||||
src/
|
||||
config/index.ts single env reader (NEXT_PUBLIC_*), branding
|
||||
lib/
|
||||
api/ typed /v1 client (ours)
|
||||
client.ts core request: cookies, envelope unwrap, ApiError
|
||||
types.ts Provider, ModelRoute, Application, Store, Chat, Account
|
||||
providers|model-routes|applications|stores|chats|account.ts
|
||||
index.ts barrel
|
||||
auth/
|
||||
iam.ts @hanzo/iam-js-sdk wrapper (browser-only), getSigninUrl
|
||||
session.tsx SessionProvider/useSession (account, signIn, signOut)
|
||||
products/
|
||||
registry.tsx ProductModule[] — the extensibility backbone
|
||||
match.ts slug -> {module, route, params}
|
||||
components/
|
||||
Provider.tsx GuiProvider + next-theme + SessionProvider (dark)
|
||||
DashboardShell.tsx sidebar (from registry) + topbar (adapts dashboard-shell recipe)
|
||||
AuthGate.tsx gate authenticated routes
|
||||
SignInForm.tsx adapts sign-in-form recipe; IAM redirect
|
||||
ui/ PageHeader, DataTable, Field*
|
||||
products/
|
||||
ProvidersModule.tsx FULL surface: list + view/edit
|
||||
providers/ logic.ts (pure cascade/visibility), List/Edit views
|
||||
ModelsModule.tsx routes list <-> new/edit
|
||||
models/ logic.ts (newModelRoute), ModelRoute List/Edit views
|
||||
ApplicationsModule.tsx routes list <-> edit
|
||||
applications/ logic.ts (newApplication), List/Edit (deploy/undeploy)
|
||||
StoresModule.tsx routes list <-> edit
|
||||
stores/ logic.ts (newStore), List (refresh-vectors)/Edit views
|
||||
ChatModule.tsx routes list <-> read-only chat view
|
||||
chat/ ChatListView + ChatView (message thread)
|
||||
```
|
||||
|
||||
Each product module mirrors Providers: a router module (`<X>Module.tsx`), a
|
||||
list view + an edit/view, and a pure `logic.ts` (new-record templates / option
|
||||
lists). Every module declares a `''` (list) and `:name` (edit/view) route in the
|
||||
registry; Models also handles `:name === 'new'` for create (model routes are
|
||||
keyed by `owner/modelName`, so modelName is form-entered, not generated).
|
||||
|
||||
## /v1 backend client
|
||||
|
||||
One `request()` in `lib/api/client.ts`: always `credentials: 'include'` (the
|
||||
backend sets a session cookie at `/v1/signin`), forwards `Accept-Language`,
|
||||
unwraps the casibase `{ status, msg, data, data2 }` envelope, throws typed
|
||||
`ApiError` (401/403 carry status). Base URL = `config.cloudUrl` (default
|
||||
`https://cloud.hanzo.ai`, override `NEXT_PUBLIC_CLOUD_URL`).
|
||||
|
||||
Endpoint surface ported from `hanzoai/ai` `web/src/backend/*.js`
|
||||
(see `docs/endpoints.md`):
|
||||
- **ProviderApi** — get-global-providers, get-providers, get-provider,
|
||||
add/update/delete-provider, refresh-mcp-tools
|
||||
- **ModelRouteApi** — get(-model-routes|-route), add/update/delete-model-route
|
||||
- **ApplicationApi** — get(-applications|-application), add/update/delete,
|
||||
deploy/undeploy-application
|
||||
- **StoreApi** — get-global-stores, get-stores, get-store, get-store-names,
|
||||
add/update/delete-store, refresh-store-vectors
|
||||
- **ChatApi** — get-global-chats, get-chats, get-chat, add/update/delete-chat
|
||||
- **AccountApi** — get-account, signin, signout
|
||||
|
||||
## Auth (Hanzo IAM)
|
||||
|
||||
`@hanzo/iam-js-sdk` against **`hanzo.id`** — the canonical OIDC issuer
|
||||
(`iss=https://hanzo.id`), the one the cloud `/v1` backend validates. `getSigninUrl()`
|
||||
builds the authorize URL (`https://hanzo.id/login/oauth/authorize?...redirect_uri=
|
||||
<origin>/auth/callback`). IAM returns `?code&state`; the callback posts them to
|
||||
`/v1/signin`, which the cloud backend exchanges and mints the session cookie;
|
||||
`useSession` then loads `/v1/get-account`.
|
||||
|
||||
App/client is **`hanzo-cloud`**, org `hanzo` — NOT a console-specific app. console2
|
||||
is a front-end OF the shared cloud `/v1` backend, which exchanges the code and
|
||||
validates the token as app `hanzo-cloud` (`aud=hanzo-cloud`), so the browser MUST
|
||||
present the same `client_id`. (The `hanzo-cloud` IAM app already whitelists
|
||||
`https://console2.hanzo.ai/auth/callback`.)
|
||||
|
||||
**Build-time gotcha (the 2026-06 sign-in bug):** every `NEXT_PUBLIC_IAM_*` is
|
||||
inlined at BUILD time (browser config), so the *image* — not runtime env — decides
|
||||
the issuer. The mainnet image MUST bake `NEXT_PUBLIC_IAM_URL=https://hanzo.id`.
|
||||
Baking `iam.hanzo.ai` (the legacy zone, `iss=https://iam.hanzo.ai`) dropped the user
|
||||
on iam.hanzo.ai with an issuer mismatch. Fixed in `src/config/index.ts` (default),
|
||||
`.env.example`, the `Dockerfile` ARG default, and the mainnet `iam_url` build-arg in
|
||||
`.github/workflows/build-image.yml`.
|
||||
|
||||
## Product-module registry (extensibility)
|
||||
|
||||
`lib/products/registry.tsx` is the single source of nav + routing truth. Each
|
||||
cloud product is a `ProductModule { id, label, icon, description, routes }`. The
|
||||
sidebar, overview, and the catch-all route all render from it. **Adding a cloud
|
||||
product = appending one entry + its module component(s); no shell or route
|
||||
edits.** A module owns its routes and components and knows nothing about
|
||||
siblings (orthogonal).
|
||||
|
||||
## Dev
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env.local # set NEXT_PUBLIC_IAM_CLIENT_ID for live auth
|
||||
npm run typecheck # tsc --noEmit (strict) — clean
|
||||
npm run build # next build (type-checks; Gui CSS injected at runtime)
|
||||
npm run dev # http://localhost:4000
|
||||
```
|
||||
|
||||
Data layer is the unified `/v1` backend — this repo is frontend only. Do NOT
|
||||
add Postgres/Mongo/etc. Do NOT build Docker images locally (CI/CD does that).
|
||||
|
||||
## Cloud console — 10-category CLOUD AXIS + embedded PaaS (feat/cloud-taxonomy-10cat)
|
||||
|
||||
The catalog (`src/lib/products/registry.tsx`) is reorganized from 6 ad-hoc
|
||||
categories to the canonical **10-category cloud axis** (the same taxonomy as the
|
||||
hanzo.ai product surface, `/tmp/hanzo-cloud-taxonomy.md`), so console2 reads like
|
||||
a cloud console (GCP/AWS) — resources grouped by cloud primitive, two rows of
|
||||
five:
|
||||
|
||||
```
|
||||
AI Compute Data Network Security
|
||||
Dev Deploy Observe Chain Apps
|
||||
```
|
||||
|
||||
**Three entry kinds, zero dead links, zero fakes** (`CatalogEntry.kind` +
|
||||
`status`):
|
||||
- `module` — in-console admin surface (Providers/Models/Chat/Stores=Vector/
|
||||
Applications, + the embedded PaaS).
|
||||
- `external`— a REAL Hanzo product on its own domain (Inference→api.hanzo.ai,
|
||||
Search→search.hanzo.ai, Bot→hanzo.bot, IAM→iam, KMS→kms, Observe/Traces/
|
||||
Dashboards→console.hanzo.ai, Analytics, Cost→billing, Object Storage→s3,
|
||||
Edge, Flow, Sign, Crawl, Studio).
|
||||
- `soon` — a real cloud primitive without a UI yet (GPUs, VPC, HSM, Settlement,
|
||||
…). Renders an HONEST in-console "coming soon" overview (`ComingSoon.tsx`,
|
||||
resolved by id from the path) that points at the API/CLI — **never a 404 and
|
||||
never a fabricated product card**. A `soon` entry is a `module` under the hood
|
||||
(single route → `ComingSoon`), so routing is unchanged.
|
||||
|
||||
The nav shell, catalog home, favorites, and router still render from the one
|
||||
`catalog` list. `status: 'soon'` shows a "Coming soon" badge + affordance.
|
||||
|
||||
### Job 3 — PaaS embedded natively under Deploy (NOT an iframe)
|
||||
|
||||
`PlatformModule.tsx` is the embedded PaaS, wired to the REAL platform.hanzo.ai
|
||||
control plane. The browser calls console2's OWN origin under `/paas/*`; the
|
||||
server route `app/paas/[...path]/route.ts` forwards to `platform.hanzo.ai/v1/*`
|
||||
with the service token from **server-only** env `PAAS_SERVICE_TOKEN` (sourced via
|
||||
KMS — never `NEXT_PUBLIC_`, never in the browser bundle, no CORS). It lists real
|
||||
apps across clusters with **declared vs running tag + drift** and a real
|
||||
health-gated **redeploy** (`POST /v1/apps/<id>/redeploy`). The six Deploy
|
||||
sub-pages (Projects/Environments/Builds/Registry/Releases/Pipelines) are tabs
|
||||
over the same real inventory. States are honest: loading, **not-configured (501
|
||||
when `PAAS_SERVICE_TOKEN` is unset)**, error, empty — it never invents rows.
|
||||
To light up real data in prod: add `PAAS_SERVICE_TOKEN` (+ optional
|
||||
`PLATFORM_URL`) to the console2 deployment env via a KMSSecret.
|
||||
|
||||
### Job 4 — no fake/placeholder/stub data
|
||||
|
||||
The catalog is honest by construction (every leaf → real module, real product
|
||||
domain, or honest `soon` overview). The PaaS embed shows only real control-plane
|
||||
data with honest empty/not-configured states. No lorem stats, no demo projects,
|
||||
no placeholder cards.
|
||||
|
||||
Build: arcd self-hosted CI (`.github/workflows/build-image.yml`, push to `main` →
|
||||
`ghcr.io/hanzoai/console2:v<package.json version>`, SEMVER only). The
|
||||
`hanzo-build-linux-amd64` ARC runner pool is the builder (online; not GHA-hosted).
|
||||
|
||||
Deploy: console2 IS an operator `Service` CR now (`hanzo.ai/v1`, `hsvc console2`,
|
||||
ns `hanzo`) — declared in `universe/infra/k8s/operator/crs/console2-v1.yaml`.
|
||||
Bump `spec.image.tag`, `kubectl apply`, the operator reconciles. Verify live with
|
||||
headless Playwright on console2.hanzo.ai.
|
||||
|
||||
## Live verification + backend wiring (v0.1.8)
|
||||
|
||||
> v0.1.7 was a parallel CTO branch (`fix/paas-live-data`) that wired only
|
||||
> Clusters/Kubernetes/Status to the platform `/v1` surface; it is integrated here
|
||||
> (`-s ours`) and superseded — v0.1.8 is the cumulative release with the full set
|
||||
> below.
|
||||
|
||||
Every embedded module was Playwright-verified live against the real `/v1` backend
|
||||
(authenticated hanzo-org admin). The backend topology console2 actually talks to:
|
||||
the same-origin `/v1` ingress routes to **cloud-api** directly (NOT the full
|
||||
api.hanzo.ai gateway), and `/paas/*` is console2's own server route → platform.
|
||||
|
||||
Findings + fixes (all in console2; honest states everywhere, no fakes):
|
||||
- **X-Org-Id (the big one).** The provisioning sub-service (vector/sql/kv/s3/
|
||||
datastore/docdb/search) requires an `X-Org-Id` header and 403s `"X-Org-Id
|
||||
required"` without it — cloud-api on the direct path does NOT inject it from the
|
||||
session. Fix: `lib/api/client.ts` now stamps `X-Org-Id: config.iamOrgName`
|
||||
(brand org, the user's own) on every cloud call (`baseHeaders`). All 7 data
|
||||
modules now return real data / honest empty `[]`.
|
||||
- **PaaS token was wrong.** The CR wired `PAAS_SERVICE_TOKEN` to
|
||||
`hanzo-paas/MASTERTOKEN` (`hanzo-master-token`), which platform.hanzo.ai
|
||||
**rejects (401)**. The correct token is in secret **`paas-console-token`** key
|
||||
`PAAS_SERVICE_TOKEN` (== `platform-service-token`). CR repointed there.
|
||||
- **Platform contract was wrong.** The real platform serves `GET /v1/apps` (the
|
||||
apps inventory: declared/running/latest tag + drift + health + cluster +
|
||||
namespace, ~100 services) and `GET|POST /v1/org/{org}/cluster` — NOT
|
||||
`/v1/clusters` and NOT any `/k8s/{kind}` passthrough (those 401/404). `lib/api/
|
||||
platform.ts` reworked to `PlatformApi.apps()` + org-scoped `listClusters`/
|
||||
`provisionCluster`; dead `KubernetesApi`/`CLUSTER_ROUTES` removed.
|
||||
- **Status** now reads `/v1/apps` → REAL health board (Services/Healthy/Clusters).
|
||||
- **Kubernetes** now reads `/v1/apps` → REAL workloads per cluster (picker from
|
||||
the clusters that actually appear).
|
||||
- **Clusters** lists real dedicated DOKS via `/v1/org/{org}/cluster` (honest
|
||||
empty; provision form wired to the real endpoint). Attach-by-kubeconfig dropped
|
||||
(no backend).
|
||||
- `interpretPlatformError` maps upstream 401/403 → honest "not configured".
|
||||
- **Bot** `/v1/bot/health` 404s on cloud-api (bot-gateway runs behind
|
||||
api.hanzo.ai/hanzo.bot, not this host) → honest "not routed on this host" state
|
||||
(was a red error).
|
||||
- **Wallet** cloud-credit `/v1/billing/balance` 404s here (billing ships
|
||||
separately) → honest "not available on this deployment" (was a scary error).
|
||||
HUSD balance/top-up already honest "coming" (token unconfigured).
|
||||
- **Providers was broken** — `ProviderListView`/`ProviderEditView` imported the
|
||||
ZAP twin (`~/lib/zap`), but the cloud `/zap` WS face is NOT served (the edge
|
||||
returns SPA HTML, 200 not a WS upgrade — documented in `lib/zap/client.ts`), so
|
||||
the module showed "Failed to load providers". Switched both back to the working
|
||||
REST `~/lib/api` (identical surface). The ZAP twin stays as the proof-of-pattern
|
||||
until `/zap` is bound. Providers now shows real/empty over REST like every module.
|
||||
- Already-correct honest states (unchanged): IAM/Audit + KMS/Secrets (`/v1/iam`,
|
||||
`/v1/kms` 404 → "not available on this deployment"); Observability (`/v1/o11y`
|
||||
503 → "runtime not initialized"). Plans/Embeddings show real data; Models/
|
||||
Providers/Applications/Chat honest-empty.
|
||||
|
||||
`StatusTag` now also understands platform health verdicts (green/yellow/red).
|
||||
|
||||
## Working AI + API keys + chrome polish (v0.6.0)
|
||||
|
||||
The investor-demo wave. ROOT CAUSE of "chats/playground don't work": the gateway
|
||||
chat endpoints REQUIRE `Authorization: Bearer` — a session cookie alone is
|
||||
rejected ("Invalid API key format"). The browser sent cookie-only, so every AI
|
||||
call (chat, playground, cmd+K `>`/`?`) failed. Fixed with two server routes that
|
||||
keep all credentials server-side (the browser only ever sends its session cookie):
|
||||
|
||||
- **`app/ai/[...path]/route.ts`** — keyless AI proxy. Resolves the user from the
|
||||
session cookie (cloud `/v1/get-account`), mints a SHORT-LIVED user-bound IAM
|
||||
token (`/v1/iam/issue-user-token`, cached per-user until ~60s pre-expiry) as the
|
||||
confidential `hanzo-console` client, and forwards to `AI_GATEWAY_URL/v1/<path>`
|
||||
with `Bearer <token>`. Allow-listed to `v1/models|chat|chat/completions|
|
||||
embeddings|rerank` (not a general tunnel). `playground.ts` now points at this
|
||||
proxy (`<origin>/ai`), so Models/Playground/Chat/cmd+K all work with no key in
|
||||
the browser and no rotation on a chat turn.
|
||||
- **`app/keys/route.ts`** — per-user `hk-` Cloud API key. POST mint/rotate, DELETE
|
||||
revoke, GET status (no secret). Same app-on-behalf pattern via
|
||||
`/v1/iam/mint-user-keys` + `/v1/iam/revoke-user-keys`. The `hk-` secret is shown
|
||||
ONCE (POST). `ApiKeysModule` is now create/copy/rotate/revoke.
|
||||
- Shared trust boundary: `src/lib/server/identity.ts` (server-only) — `resolveUser`
|
||||
+ `mintUserKey`/`revokeUserKey`/`issueUserToken`. The `hanzo-console` client is
|
||||
allow-listed in IAM `IAM_KEY_MINT_ALLOWED_APPS`; verified end-to-end that a
|
||||
minted `hk-` key and an issued user JWT both 200 on `api.hanzo.ai/v1/chat/
|
||||
completions`.
|
||||
- **Chat is interactive** (`chat/ChatConversation.tsx`): a real multi-turn
|
||||
conversation over `AiApi.chat` (→ the `/ai` proxy), with a Zen default model,
|
||||
honest 402 "add credits" state, and a "History" toggle to the old session list.
|
||||
- **Chrome**: the sidebar/header show the Hanzo **H mark + "Console"**
|
||||
(`ui/HanzoMark.tsx` + `ui/BrandLogo.tsx`; `BrandLogo` shows the org's IAM logo
|
||||
when set, else the H). A fullscreen **app launcher** (`components/AppLauncher.tsx`,
|
||||
Launchpad-style grid + filter) opens from the header "Apps" button, the sidebar
|
||||
grid icon, and the command palette's "Browse all apps". cmd+K stays the palette.
|
||||
|
||||
Server-only env the routes need (added to `console2-v1.yaml`, never `NEXT_PUBLIC_`):
|
||||
`IAM_URL`, `CLOUD_API_URL` (in-cluster cloud-api), `AI_GATEWAY_URL` (api.hanzo.ai),
|
||||
and `IAM_MINT_CLIENT_ID`/`IAM_MINT_CLIENT_SECRET` from secret `hanzo-console-iam-creds`.
|
||||
|
||||
## Admin console live data + org switching (v0.7.0)
|
||||
|
||||
The "models empty" + "org switcher broken" wave. ONE root cause: `/v1/iam`,
|
||||
`/v1/kms`, `/v1/models` 404 (or 401 cookie-only) on the console host, so the
|
||||
catalog, switcher, IAM, and KMS modules rendered honest-empty. Fix = route every
|
||||
privileged call through console2's OWN server proxies (which add the user bearer +
|
||||
the admin gate), and make org scope a first-class, switchable value.
|
||||
|
||||
- **Model catalog (the "models missing" bug).** `CloudModelApi.list()` hit cloud
|
||||
`/v1/models` with a cookie only → 401 → empty. Repointed at the `/ai` proxy via
|
||||
the shared `aiV1Url('models')` (`lib/api/client.ts` now owns `aiBase`/`aiV1Url` —
|
||||
ONE place defines the proxy origin; `playground.ts` uses it too). The proxy mints
|
||||
a short-lived user token, so the catalog populates with the live Zen models.
|
||||
Pricing stays best-effort on the cloud origin (degrades to "—", never fabricated).
|
||||
- **Org scope is a value, not a place** (`lib/org-scope.ts`). `currentOrg()` /
|
||||
`setCurrentOrg()` / `isScopedAway()` / `filterOrgs()`. Default = the brand org;
|
||||
a global admin (z@hanzo.ai) can switch to ANY org. Brand identity (host wordmark/
|
||||
logo) is orthogonal and unchanged — only the DATA scope moves. `client.ts`
|
||||
`baseHeaders` now stamps `X-Org-Id: currentOrg()` (was the fixed brand org), so
|
||||
every cloud-data module re-scopes on switch.
|
||||
- **OrgSwitcher** lists ALL visible orgs (`IamAdminApi.organizations()` via the
|
||||
`/admin/iam` proxy → global admin sees every org), adds a **filter** box
|
||||
(`filterOrgs`), and **switches in place**: `setCurrentOrg` + reload refetches
|
||||
every module under the new `X-Org-Id`. The IAM/KMS proxies authorize a global
|
||||
admin for any org and pin a brand admin to their own, so the re-scope is safe.
|
||||
- **IAM module** (`AdminModule.tsx` `IamModule`/`AuditModule`) reads users/roles/
|
||||
records for `currentOrg()` (the org list itself is unscoped — what powers the
|
||||
switcher). **KMS module** (`KmsModule.tsx`) was a dead cloud-path probe; now a
|
||||
names-only inventory over `KmsAdminApi.list({ org })` (the `/admin/kms` proxy +
|
||||
kmsd's metadata-list endpoint, v0.159.4+). Values are NEVER fetched/rendered;
|
||||
honest states: loading, operator-access-required (403), listing-unavailable (404),
|
||||
empty.
|
||||
- **Decomplected gate** (`lib/server/admin-policy.ts`, pure, tested). `gateAllows`
|
||||
(`@<adminDomain>` email AND IAM admin), `ownerAllowed`, `orgFor` — extracted from
|
||||
`getAdminGate` + the IAM/KMS routes so the SAME predicate that ships is the one
|
||||
unit-tested. A brand admin can never `orgFor` to another org's KMS (no secret
|
||||
leak across orgs).
|
||||
- **Tests** (vitest, `npm test`): `admin-policy.test.ts` (gate allow/deny + tenant
|
||||
scoping), `org-scope.test.ts` (default→switch→reset + filter), `models-catalog.
|
||||
test.ts` (catalog fetches `<origin>/ai/v1/models`, never the cookie-only cloud
|
||||
path). RED→GREEN, 22 tests. `tsc --noEmit` + `next build` clean.
|
||||
- The console2 CR already carries the env the proxies need (`IAM_URL`,
|
||||
`CLOUD_API_URL`, `AI_GATEWAY_URL`, `IAM_MINT_CLIENT_*`); `KMS_URL` defaults to
|
||||
`http://kms.hanzo.svc`. `admin.hanzo.ai` added to the CR ingress hosts.
|
||||
|
||||
## First-run org onboarding + waitlists (v0.7.8)
|
||||
|
||||
This release is based on the live GitHub `main` at `88d4c68` (v0.7.7), including
|
||||
the org → project → environment scope model. `Projects` remains a single Deploy
|
||||
module backed by the IAM project endpoints and the top-bar `ScopeSwitcher`; do
|
||||
not add a duplicate Apps/Projects entry.
|
||||
|
||||
- **One instruction source.** `AGENTS.md` and `CLAUDE.md` are symlinks to
|
||||
`LLM.md`; keep agent guidance here only so Codex and Claude stay in sync.
|
||||
- **First-run org onboarding.** `OrgGate` now sends signed-in users with no org
|
||||
to `OrgOnboarding` instead of a dead "no organization" state. The same-origin
|
||||
`/onboard` route acts as the confidential `hanzo-console` IAM client, creates
|
||||
a customer org (or personal org), moves the caller into it as admin, then the
|
||||
client re-authenticates so the new session carries the org. Normal privileged
|
||||
routes still use `resolveUser()` (org required); onboarding alone uses
|
||||
`resolveAuthenticatedUser()` so it can handle the zero-org session safely.
|
||||
- **Coming-soon waitlists.** `ComingSoon` renders `WaitlistForm`, which posts to
|
||||
`/waitlist`. The server route requires a session and forwards to
|
||||
`WAITLIST_URL/v1/waitlist/join`; when `WAITLIST_URL` is unset it returns an
|
||||
honest 501 and never fabricates a confirmation.
|
||||
- **Motion primitive.** `FadeIn` plus the single `.hz-fade-up` keyframe in
|
||||
`globals.css` is the shared entrance animation. It honors
|
||||
`prefers-reduced-motion`.
|
||||
|
||||
Server-only env added by this wave: `WAITLIST_URL` for waitlist forwarding. Org
|
||||
onboarding uses the existing `IAM_URL`, `CLOUD_API_URL`, and
|
||||
`IAM_MINT_CLIENT_ID`/`IAM_MINT_CLIENT_SECRET` confidential-client wiring.
|
||||
|
||||
## CI base image mirror (v0.7.9)
|
||||
|
||||
The ARC runner was still blocked by Docker Hub's unauthenticated pull limit while
|
||||
pre-pulling `node:22-alpine`. `Dockerfile` now uses
|
||||
`public.ecr.aws/docker/library/node:22-alpine` for all stages, and
|
||||
`.github/workflows/build-image.yml` pre-pulls the same ECR Public Docker-library
|
||||
mirror image. This keeps the host-builder cache behavior but removes Docker Hub
|
||||
from the cold-runner path.
|
||||
|
||||
## Console parity audit + remaining feature ports (2026-06-29)
|
||||
|
||||
Old `console/web/src/pages/project/[projectId]` still had routes with no
|
||||
console2 destination: experiments, dashboard/widgets, integrations
|
||||
(blob-storage, Slack, Mixpanel, Insights), referrals, zero-trust, prompt
|
||||
detail/create/metrics, dataset items/runs, annotation queue detail/items, and
|
||||
score analytics. These are now represented in console2 without copying the old
|
||||
Langfuse internals.
|
||||
|
||||
- `ConsoleFeatureModule.tsx` is the shared forward-compatible shell: each moved
|
||||
surface declares its exact `/v1` endpoint, renders real rows when the endpoint
|
||||
exists, and uses `BackendStateCard` for 404/405/503/access/billing. It never
|
||||
fabricates rows.
|
||||
- New catalog entries: `experiments`, `integrations`, `referrals`, and
|
||||
`zero-trust`. `dashboards` is now a native module with an external handoff to
|
||||
analytics.hanzo.ai instead of external-only. `scores/analytics` is routed as a
|
||||
score subpage.
|
||||
- Expanded existing modules: Prompts now has list/detail/create/metrics routes;
|
||||
Datasets has datasets/items/runs; Annotation Queues has queue detail + work
|
||||
items. `EvalsApi` and `O11yApi` carry the corresponding typed
|
||||
forward-compatible methods.
|
||||
- Verification for this wave: `npm run typecheck` and `npm test` both pass
|
||||
locally (48 Vitest tests).
|
||||
|
||||
## Embeddings — full product surface (feat/embeddings-page)
|
||||
|
||||
The `embeddings` catalog entry is upgraded from the old single Stores admin
|
||||
(`StoresModule`, now deleted along with `StoreListView` — superseded, not
|
||||
duplicated) into a six-tab product over the REAL `hanzoai/ai` `/v1` backend.
|
||||
`StoreEditView` + `stores/logic.ts` (`newStore`) are reused for the collection
|
||||
editor + create; nothing is forked.
|
||||
|
||||
- **EmbeddingsModule** routes `''`→Overview, `:tab`→Overview/Explore/Collections/
|
||||
Jobs/Models/Settings, `collections/:name`→the store editor (unambiguous by
|
||||
segment count — same pattern as Models' `:tab`). The ONE create path
|
||||
(`add-store` + `newStore`) backs both the header "Create collection" and the
|
||||
Collections "New".
|
||||
- **Collections = stores.** `EmbeddingsApi.collections` = `get-stores` → a
|
||||
`Collection` view-model; each maps to the Qdrant/Search index
|
||||
`{owner}-{store}-docs` (the backend's `GetSearchIndexName`). The store object
|
||||
carries NO vector count / dimension / index-size / `updatedTime`, and the metric
|
||||
is fixed to cosine at index-create — so those columns render an honest "—"
|
||||
(CREATED shows `createdTime`, the only timestamp; the metric is the real cosine).
|
||||
- **Explore** → real `POST /v1/search?store=` (`{query,limit,mode}` → `{hits}`).
|
||||
Hits carry no per-hit score (backend RRF-drops it) so score reads "—";
|
||||
url/breadcrumbs are the locator. Model+Dimension are read-only collection
|
||||
metadata (search uses the store's own embedding config, not query-time).
|
||||
Vector-inspect is honest-empty (no point-lookup endpoint).
|
||||
- **Models** = `/v1/models` filtered by id (no category field exists; many
|
||||
embedding routes are `hidden` and absent — honest); generate = real
|
||||
`POST /v1/embeddings` via the keyless `/ai` proxy (already allow-listed).
|
||||
- **Jobs** = per-file index status (`get-files`: Pending/Processing/Finished/
|
||||
Error — there is no async job entity) + a real upload ingest
|
||||
(`POST /v1/docs/ingest`, source=upload).
|
||||
- **Overview** metric cards (vectors/storage/queries/latency/cost) read
|
||||
`GET /v1/get-cloud-usages` — a forward-compatible client coded to the documented
|
||||
shape that degrades EVERY field to "—" with no sparkline today (the read API
|
||||
has no unique commits yet on `feat/cloud-usage-read-api`). The model donut is
|
||||
the real collection-by-model mix; dimension bars light up when metering reports
|
||||
per-dimension counts; index-health is per-collection state enriched best-effort
|
||||
with live `/v1/search/stats`.
|
||||
- New: `src/lib/api/embeddings.ts` (`EmbeddingsApi`), the pure
|
||||
`components/products/embeddings/logic.ts` (+15 Vitest), and the dependency-free
|
||||
`components/ui/Charts.tsx` (Sparkline/Donut/BarChart — monochrome SVG, render
|
||||
nothing/"—" rather than a fabricated trend).
|
||||
- Shared-infra touched: registry (`embeddings` row upgraded, not duplicated),
|
||||
`lib/api/index.ts` (barrel export). Reuses the unified `EmptyState`,
|
||||
`BackendStateCard`, `DataTable`, `PageHeader`, `PrimaryButton`, `Field*`.
|
||||
- Drive-by: corrected a STALE assertion in `admin-policy.test.ts` — the
|
||||
`built-in` org-metadata owner was deliberately dropped in v0.7.15 (9b59dec,
|
||||
"standardize the global-admin org on 'admin'") but the test still expected the
|
||||
old wider allow-set; the gate code is unchanged (the tighter shipped policy is
|
||||
the correct one).
|
||||
- Verification: `tsc --noEmit` clean, `vitest` 67/67 (8 files), `next build`
|
||||
green (14/14 pages). Authenticated visual e2e is gated behind a deploy + IAM
|
||||
session — left for live verification (the catch-all `/[...slug]` route that
|
||||
renders this compiled and type-checked).
|
||||
|
||||
## Native control planes — ZERO external link-outs + Hanzo Functions (claude/console2-native-control-planes)
|
||||
|
||||
Three deliverables, one PR, all over the one `/v1` surface (no `/api/` prefixes).
|
||||
|
||||
- **No external link-outs (the priority).** The catalog's `external` kind is GONE:
|
||||
`CatalogEntry` is now `module`-only and `ProductStatus` is `'enabled' | 'soon'`.
|
||||
Every product that used to open another domain in a tab (Gateway, DNS, CDN, MPC,
|
||||
CLI, SDKs, API, IDE, Desktop, Registry, Metrics, Crawl, Studio, Console) is now a
|
||||
native in-console route. They render ONE shared `NativeOverview`
|
||||
(`components/products/overview/NativeOverview.tsx`, wired via `overviewFor(id)` +
|
||||
`overviewRoutes(id)` in the registry — the DRY twin of `soonRoutes`): header +
|
||||
what-it-is, a REAL health band (probes `PlatformApi.apps()` for the product's
|
||||
operator service; honest "not deployed / not reporting" states, never a fabricated
|
||||
"operational"), key-fact cards (honest "—"), native primary actions (in-console
|
||||
routes only), and INLINE docs (rendered in-console; the docs SITE is a small
|
||||
secondary reference, never the way to use the product). Content is a pure
|
||||
`OverviewSpec` per product (`overview/spec.ts` + `resolve.ts` — a catalog-derived
|
||||
`defaultSpec` covers any product with no bespoke spec). The `external` branches in
|
||||
`open.ts`, `DashboardShell`, `AppLauncher`, `CommandPalette`, `ProductInterstitial`,
|
||||
and `OverviewModule` are removed — there is one way to open anything: a native route.
|
||||
- **Hanzo Functions dashboard.** `FunctionsModule` is rebuilt from the old single
|
||||
`/paas/functions` table into a polished tabbed product (Overview · Functions ·
|
||||
Deployments · Triggers · Secrets · Settings, `:tab` route like GPUs/Models) over
|
||||
the rich `lib/api/functions.ts` (`GET /v1/functions*`). Branded **Hanzo Functions**
|
||||
with the honest **Fission** engine badge (the mock said "OpenFaaS", but the live
|
||||
engine per `go.mod` + `universe/infra/k8s/functions` is Fission — we label the real
|
||||
one). Overview: 6 KPI cards (Functions/Invocations 7D/Success/Avg duration/Errors/
|
||||
Cost) derived from real rows via `deriveOverview` (each degrades to "—"), with real
|
||||
series sparklines + `trendPct` deltas; an "Invocations over time" `LineChart` with
|
||||
1H/6H/24H/7D/30D range toggles; an "Invocation status" `Donut`; and the shared
|
||||
`FunctionsBrowser` (table + `DetailRail`, DRY across Overview and the Functions tab).
|
||||
All chart/donut/cost read `FunctionsApi.metrics(range)`; until that route is bound
|
||||
they show honest "time-series not connected" — never a placeholder trend. Secrets is
|
||||
names-only (values never fetched — Secret Manager principle). `functions/{FunctionsTable,
|
||||
DetailRail,parts}.tsx` (already built on the feature branch) are reused unchanged.
|
||||
- **Overview "Explore products" — enablement gate dropped.** The home cards lose the
|
||||
Enabled/External/Soon `StatusBadge` and the open-vs-learn gate; every product is
|
||||
open-for-all with an "Open" (native) + a "Learn more" affordance (the native
|
||||
`/discover/:id` interstitial, which itself surfaces docs + OSS source inline — not a
|
||||
link-out).
|
||||
- Idiom: strictly `@hanzo/gui` v5 shorthands (`bg`/`maxW`/`rounded`/`items`/`self`/
|
||||
`p`/`px`/`py`/`gap`), matching every existing module. New tests: `overview/resolve.test.ts`
|
||||
(spec resolution + honest default; the no-`external`/no-`href` invariant is enforced at
|
||||
compile time by the collapsed `CatalogEntry` union). Verification: `npm run typecheck`
|
||||
clean (0 errors), `npm test` 298/298 (31 files), and every route (`/functions/*`, all
|
||||
native overviews, `/`, `/discover/:id`) compiles + returns 200 on the dev server.
|
||||
- Repo drive-by: removed the bogus tracked `node_modules` self-symlink blob (mode
|
||||
120000 → itself) that broke `npm install`/`vitest`; `.gitignore` already ignores
|
||||
`node_modules/`, so it was never meant to be committed.
|
||||
@@ -1,193 +1,51 @@
|
||||

|
||||
<p align="center"><img src=".github/hero.svg" alt="console2" width="880"></p>
|
||||
|
||||
<div align="center"><h1>Langfuse: Open Source LLM Engineering Platform</h1></div>
|
||||
<div align="center"><h4>LLM Observability, Prompt Management, LLM Evaluations,<br/>Datasets, LLM Metrics, and Prompt Playground</h4></div>
|
||||
# Hanzo Cloud Console
|
||||
|
||||
<div align="center">
|
||||
<div>
|
||||
<h3>
|
||||
<a href="https://cloud.langfuse.com">
|
||||
<strong>Sign up</strong>
|
||||
</a> ·
|
||||
<a href="https://langfuse.com/docs/deployment/self-host">
|
||||
<strong>Self Host</strong>
|
||||
</a> ·
|
||||
<a href="https://langfuse.com/demo">
|
||||
<strong>Demo (live data)</strong>
|
||||
</a>
|
||||
</h3>
|
||||
</div>
|
||||
<div>
|
||||
<a href="https://langfuse.com/docs"><strong>Docs</strong></a> ·
|
||||
<a href="https://langfuse.com/issues"><strong>Report Bug</strong></a> ·
|
||||
<a href="https://langfuse.com/ideas"><strong>Feature Request</strong></a> ·
|
||||
<a href="https://langfuse.com/changelog"><strong>Changelog</strong></a> ·
|
||||
<a href="https://langfuse.com/roadmap"><strong>Roadmap</strong></a> ·
|
||||
<a href="https://langfuse.com/discord"><strong>Discord</strong></a>
|
||||
</div>
|
||||
<span>Langfuse uses <a href="https://github.com/orgs/langfuse/discussions"><strong>Github Discussions</strong></a> for Support and Feature Requests.</span>
|
||||
<br/>
|
||||
<span>We're hiring. <a href="https://langfuse.com/careers"><strong>Join us</strong></a> in Product Engineering and Developer Relations.</span>
|
||||
<br/>
|
||||
<br/>
|
||||
<div>
|
||||
<a href="https://github.com/langfuse/langfuse/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-red.svg?style=flat-square" alt="MIT License"></a>
|
||||
<a href="https://www.ycombinator.com/companies/langfuse"><img src="https://img.shields.io/badge/Y%20Combinator-W23-orange?style=flat-square" alt="Y Combinator W23"></a>
|
||||
<a href="https://github.com/langfuse/langfuse/pkgs/container/langfuse"><img alt="Docker Image" src="https://img.shields.io/badge/docker-langfuse-blue?logo=Docker&logoColor=white&style=flat-square"></a>
|
||||
<a href="https://pypi.python.org/pypi/langfuse"><img src="https://img.shields.io/pypi/dm/langfuse?style=flat-square&logo=python&logoColor=white&label=pypi%20langfuse&color=blue" alt="langfuse Python package on PyPi"></a>
|
||||
<a href="https://www.npmjs.com/package/langfuse"><img src="https://img.shields.io/npm/dm/langfuse?style=flat-square&logo=npm&logoColor=white&label=npm%20langfuse&color=blue" alt="langfuse npm package"></a>
|
||||
</div>
|
||||
</div>
|
||||
</br>
|
||||
Unified admin console for **Hanzo Cloud** and all Hanzo cloud products. Built on
|
||||
[@hanzo/gui](https://gui.hanzo.ai) (cross-platform UI) over the unified `/v1`
|
||||
backend (`hanzoai/cloud`). Dark theme, OIDC sign-in via Hanzo IAM.
|
||||
|
||||
## Langfuse Overview
|
||||
Manages: **Providers · Models · Applications · Stores · Chat** — with an
|
||||
extensible product-module registry so every cloud product can be added as a
|
||||
module.
|
||||
|
||||
_Unmute video for voice-over_
|
||||
|
||||
https://github.com/langfuse/langfuse/assets/2834609/a94062e9-c782-4ee9-af59-dee6370149a8
|
||||
|
||||
### Develop
|
||||
|
||||
- **LLM Observability:** Instrument your app and start ingesting traces to Langfuse ([Quickstart](https://langfuse.com/docs/get-started), [Integrations](https://langfuse.com/docs/integrations) [Tracing](https://langfuse.com/docs/tracing))
|
||||
- **Langfuse UI:** Inspect and debug complex logs ([Demo](https://langfuse.com/docs/demo), [Tracing](https://langfuse.com/docs/tracing))
|
||||
- **Prompt Management:** Manage, version and deploy prompts from within Langfuse ([Prompt Management](https://langfuse.com/docs/prompts/get-started))
|
||||
- **Prompt Engineering:** Test and iterate on your prompts with the [LLM Playground](https://langfuse.com/docs/playground)
|
||||
|
||||
### Monitor
|
||||
|
||||
- **LLM Analytics:** Track metrics (cost, latency, quality) and gain insights from dashboards & data exports ([Analytics](https://langfuse.com/docs/analytics))
|
||||
- **LLM Evaluations:** Collect and calculate scores for your LLM completions ([Scores & Evaluations](https://langfuse.com/docs/scores))
|
||||
- Run ([Model-based evaluations](https://langfuse.com/docs/scores/model-based-evals)) and LLM-as-a-Judge within Langfuse
|
||||
- Collect user feedback ([User Feedback](https://langfuse.com/docs/scores/user-feedback))
|
||||
- Manually score LLM outputs in Langfuse ([Manual Scores](https://langfuse.com/docs/scores/manually))
|
||||
|
||||
### Test
|
||||
|
||||
- **Experiments:** Track and test app behaviour before deploying a new version
|
||||
- Datasets let you test expected in and output pairs and benchmark performance before deploying ([Datasets](https://langfuse.com/docs/datasets))
|
||||
- Track versions and releases in your application ([Experimentation](https://langfuse.com/docs/experimentation), [Prompt Management](https://langfuse.com/docs/prompts))
|
||||
|
||||
## Get started
|
||||
|
||||
### Langfuse Cloud
|
||||
|
||||
Managed deployment by the Langfuse team, generous free-tier (hobby plan), no credit card required.
|
||||
|
||||
**[» Langfuse Cloud](https://cloud.langfuse.com)**
|
||||
|
||||
## Self-Hosting Open Source LLM Observability with Langfuse
|
||||
|
||||
### Localhost (docker)
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://github.com/langfuse/langfuse.git
|
||||
cd langfuse
|
||||
|
||||
# Run server and database
|
||||
docker compose up -d
|
||||
npm install
|
||||
cp .env.example .env.local
|
||||
# set NEXT_PUBLIC_IAM_CLIENT_ID for live sign-in; defaults point at production.
|
||||
npm run dev # http://localhost:4000
|
||||
```
|
||||
|
||||
[→ Learn more about deploying locally](https://langfuse.com/docs/deployment/local)
|
||||
## Scripts
|
||||
|
||||
### Self-host (docker)
|
||||
| Script | What |
|
||||
| --- | --- |
|
||||
| `npm run dev` | Dev server on :4000 |
|
||||
| `npm run build` | Production build (type-checks; Gui CSS injected at runtime) |
|
||||
| `npm run start` | Serve the production build |
|
||||
| `npm run typecheck` | `tsc --noEmit` (strict) |
|
||||
|
||||
Langfuse is simple to self-host and keep updated. It currently requires only a single docker container and a postgres database.
|
||||
[→ Self Hosting Instructions](https://langfuse.com/docs/deployment/self-host)
|
||||
## Configuration
|
||||
|
||||
Templated deployments: [Railway, GCP, AWS, Azure, Kubernetes and others](https://langfuse.com/docs/deployment/self-host#platform-specific-information)
|
||||
All config is `NEXT_PUBLIC_*` (browser app, cookie auth). See `.env.example`.
|
||||
|
||||
## Get Started
|
||||
| Var | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `NEXT_PUBLIC_CLOUD_URL` | `https://cloud.hanzo.ai` | Unified `/v1` backend base URL |
|
||||
| `NEXT_PUBLIC_IAM_URL` | `https://iam.hanzo.ai` | Hanzo IAM OIDC authority |
|
||||
| `NEXT_PUBLIC_IAM_APP_NAME` | `hanzo-console` | IAM application (`<org>-<app>`) |
|
||||
| `NEXT_PUBLIC_IAM_ORG_NAME` | `hanzo` | IAM organization |
|
||||
| `NEXT_PUBLIC_IAM_CLIENT_ID` | — | OAuth client id |
|
||||
|
||||
### API Keys
|
||||
## Architecture
|
||||
|
||||
You need a Langfuse public and secret key to get started. Sign up [here](https://cloud.langfuse.com) and find them in your project settings.
|
||||
|
||||
### Ingesting Data · Instrumenting Your Application · LLM Observability with Langfuse
|
||||
|
||||
Note: We recommend using our fully async, typed [SDKs](https://langfuse.com/docs/sdk) that allow you to instrument any LLM application with any underlying model. They are available in [Python (Decorators)](https://langfuse.com/docs/sdk/python) & [JS/TS](https://langfuse.com/docs/sdk/typescript). The SDKs will always be the most fully featured and stable way to ingest data into Langfuse.
|
||||
|
||||
See the [→ Quickstart](https://langfuse.com/docs/get-started) to integrate Langfuse.
|
||||
|
||||
### LLM Observability Integrations
|
||||
|
||||
| Integration | Supports | Description |
|
||||
| ---------------------------------------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [SDK](https://langfuse.com/docs/sdk) | Python, JS/TS | Manual instrumentation using the SDKs for full flexibility. |
|
||||
| [OpenAI](https://langfuse.com/docs/integrations/openai) | Python, JS/TS | Automated instrumentation using drop-in replacement of OpenAI SDK. |
|
||||
| [Langchain](https://langfuse.com/docs/integrations/langchain) | Python, JS/TS | Automated instrumentation by passing callback handler to Langchain application. |
|
||||
| [LlamaIndex](https://langfuse.com/docs/integrations/llama-index/get-started) | Python | Automated instrumentation via LlamaIndex callback system. |
|
||||
| [Haystack](https://langfuse.com/docs/integrations/haystack) | Python | Automated instrumentation via Haystack content tracing system. |
|
||||
| [LiteLLM](https://langfuse.com/docs/integrations/litellm) | Python, JS/TS (proxy only) | Use any LLM as a drop in replacement for GPT. Use Azure, OpenAI, Cohere, Anthropic, Ollama, VLLM, Sagemaker, HuggingFace, Replicate (100+ LLMs). |
|
||||
| [Vercel AI SDK](https://langfuse.com/docs/integrations/vercel-ai-sdk) | JS/TS | TypeScript toolkit designed to help developers build AI-powered applications with React, Next.js, Vue, Svelte, Node.js. |
|
||||
| [API](https://langfuse.com/docs/api) | | Directly call the public API. OpenAPI spec available. |
|
||||
|
||||
Packages integrated with Langfuse:
|
||||
|
||||
| Name | Description |
|
||||
| --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| [Instructor](https://langfuse.com/docs/integrations/instructor) | Library to get structured LLM outputs (JSON, Pydantic) |
|
||||
| [Dify](https://langfuse.com/docs/integrations/dify) | Open source LLM app development platform with no-code builder. |
|
||||
| [Ollama](https://langfuse.com/docs/integrations/ollama) | Easily run open source LLMs on your own machine. |
|
||||
| [Mirascope](https://langfuse.com/docs/integrations/mirascope) | Python toolkit for building LLM applications. |
|
||||
| [Flowise](https://langfuse.com/docs/integrations/flowise) | JS/TS no-code builder for customized LLM flows. |
|
||||
| [Langflow](https://langfuse.com/docs/integrations/langflow) | Python-based UI for LangChain, designed with react-flow to provide an effortless way to experiment and prototype flows. |
|
||||
|
||||
## Questions and feedback
|
||||
|
||||
### Ideas and roadmap
|
||||
|
||||
- [Roadmap](https://langfuse.com/roadmap)
|
||||
- [GitHub Discussions](https://github.com/orgs/langfuse/discussions)
|
||||
- [Feature Requests](https://langfuse.com/ideas)
|
||||
|
||||
### Support and feedback
|
||||
|
||||
In order of preference the best way to communicate with us:
|
||||
|
||||
- [GitHub Discussions](https://github.com/orgs/langfuse/discussions) (preferred): Contribute [ideas](https://langfuse.com/ideas), [support requests](https://langfuse.com/gh-support) and [report bugs](https://langfuse.com/issues)
|
||||
- [Discord](https://langfuse.com/discord): community support
|
||||
- Privately: contact at langfuse dot com
|
||||
|
||||
## Contributing to Langfuse
|
||||
|
||||
- Vote on [Ideas](https://github.com/orgs/langfuse/discussions/categories/ideas)
|
||||
- Raise and comment on [Issues](https://github.com/langfuse/langfuse/issues)
|
||||
- Open a PR - see [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to setup a development environment.
|
||||
See [LLM.md](./LLM.md) for the full design (base choice, /v1 client, auth flow,
|
||||
the product-module registry, and the Providers surface). Endpoint reference in
|
||||
[docs/endpoints.md](./docs/endpoints.md).
|
||||
|
||||
## License
|
||||
|
||||
This repository is MIT licensed, except for the `ee` folders. See [LICENSE](LICENSE) and [docs](https://langfuse.com/docs/open-source) for more details.
|
||||
|
||||
## Misc
|
||||
|
||||
### GET API to export your data
|
||||
|
||||
[**GET routes**](https://langfuse.com/docs/integrations/api) to use data in downstream applications (e.g. embedded analytics). You can also access them conveniently via the SDKs ([docs](https://langfuse.com/docs/query-traces)).
|
||||
|
||||
### Security & Privacy
|
||||
|
||||
We take data security and privacy seriously. Please refer to our [Security and Privacy](https://langfuse.com/security) page for more information.
|
||||
|
||||
### Telemetry
|
||||
|
||||
By default, Langfuse automatically reports basic usage statistics of self-hosted instances to a centralized server (PostHog).
|
||||
|
||||
This helps us to:
|
||||
|
||||
1. Understand how Langfuse is used and improve the most relevant features.
|
||||
2. Track overall usage for internal and external (e.g. fundraising) reporting.
|
||||
|
||||
None of the data is shared with third parties and does not include any sensitive information. We want to be super transparent about this and you can find the exact data we collect [here](/web/src/features/telemetry/index.ts).
|
||||
|
||||
You can opt-out by setting `TELEMETRY_ENABLED=false`.
|
||||
|
||||
### Star History
|
||||
|
||||
<a href="https://star-history.com/#langfuse/langfuse&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=langfuse/langfuse&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=langfuse/langfuse&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=langfuse/langfuse&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
BSD-3-Clause. Copyright (c) 2026-present, Hanzo AI, Inc.
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
## Security Policy
|
||||
We strongly recommend using the latest version of Langfuse to receive all security updates.
|
||||
|
||||
For more information, please refer to the [Data Security & Privacy](https://langfuse.com/docs/data-security-privacy) page in the documentation or contact security@langfuse.com.
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client'
|
||||
|
||||
import { use } from 'react'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
import { resolveView, isAdminRoute } from '~/lib/products/match'
|
||||
import { findEntry } from '~/lib/products/registry'
|
||||
import { useIsGlobalAdmin } from '~/lib/auth/admin'
|
||||
import { ProductSubpageStub } from '~/components/products/ProductSubpageStub'
|
||||
import { AdminManagedNotice } from '~/components/products/AdminManagedNotice'
|
||||
|
||||
/**
|
||||
* Catch-all product route. Resolves the module + route from the registry and
|
||||
* renders its component. Adding a product anywhere in the registry makes its
|
||||
* routes live here — no per-product page files.
|
||||
*
|
||||
* Two honest gates on top of the resolver:
|
||||
* - A known product SUB-PAGE (a declared specific or a uniform base sub-page:
|
||||
* Overview · Settings · Status · Logs · Metrics) with no backend route yet
|
||||
* renders a placeholder stub — never a 404, never a fabricated surface.
|
||||
* - A CUSTOMER (non-global-admin) reaching an admin-only surface (cross-tenant
|
||||
* IAM/KMS, provider + routing config) gets a graceful "managed by Hanzo" notice
|
||||
* instead of the module's hostile 403 red error. Access is enforced
|
||||
* server-side regardless.
|
||||
*/
|
||||
export default function ProductPage({ params }: { params: Promise<{ slug: string[] }> }) {
|
||||
const { slug } = use(params)
|
||||
const showAdmin = useIsGlobalAdmin()
|
||||
const view = resolveView(slug)
|
||||
|
||||
if (view.kind === 'notfound') notFound()
|
||||
|
||||
if (!showAdmin && isAdminRoute(slug)) {
|
||||
const entry = findEntry(slug[0])
|
||||
if (entry && entry.kind === 'module') {
|
||||
const seg = slug[1]
|
||||
const subpage = seg ? (entry.subpages ?? []).find((s) => s.slug === seg && s.admin) : undefined
|
||||
return <AdminManagedNotice entry={entry} subpage={subpage} />
|
||||
}
|
||||
}
|
||||
|
||||
if (view.kind === 'stub') return <ProductSubpageStub entry={view.entry} subpage={view.subpage} />
|
||||
|
||||
const Component = view.matched.route.component
|
||||
return <Component params={view.matched.params} />
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { use } from 'react'
|
||||
|
||||
import { ProductInterstitial } from '~/components/products/ProductInterstitial'
|
||||
|
||||
/**
|
||||
* Product discover screen — `/discover/<id>` renders the interstitial for one
|
||||
* catalog entry (docs, OSS source, revenue share, open/get-started). A dedicated
|
||||
* route (more specific than the `[...slug]` product catch-all) so it's shareable.
|
||||
*/
|
||||
export default function DiscoverPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params)
|
||||
return <ProductInterstitial id={id} />
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { AuthGate } from '~/components/AuthGate'
|
||||
import { OrgGate } from '~/components/OrgGate'
|
||||
import { DashboardShell } from '~/components/DashboardShell'
|
||||
import { PreferencesProvider } from '~/lib/products/preferences'
|
||||
import { ScopeProvider } from '~/lib/scope-context'
|
||||
import { ToastProvider } from '~/components/ui/Toast'
|
||||
import { CommandPaletteProvider } from '~/components/CommandPalette'
|
||||
import { AppLauncherProvider } from '~/components/AppLauncher'
|
||||
import { FloatingChatProvider } from '~/components/FloatingChat'
|
||||
|
||||
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<AuthGate>
|
||||
<OrgGate>
|
||||
<ScopeProvider>
|
||||
<PreferencesProvider>
|
||||
<ToastProvider>
|
||||
{/* AppLauncher wraps the palette so the palette can open the launcher. */}
|
||||
<AppLauncherProvider>
|
||||
<CommandPaletteProvider>
|
||||
{/* FloatingChat floats the assistant bubble over every page. */}
|
||||
<FloatingChatProvider>
|
||||
<DashboardShell>{children}</DashboardShell>
|
||||
</FloatingChatProvider>
|
||||
</CommandPaletteProvider>
|
||||
</AppLauncherProvider>
|
||||
</ToastProvider>
|
||||
</PreferencesProvider>
|
||||
</ScopeProvider>
|
||||
</OrgGate>
|
||||
</AuthGate>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Product catalog — the unified console home. Every Hanzo product, grouped by the
|
||||
* ten canonical categories, with its Google Cloud equivalent. Every product is
|
||||
* open-for-all: each card opens straight into its native in-console surface and
|
||||
* carries a "Learn more" affordance to its docs — there is no enablement gate and
|
||||
* no external bounce. Each card can be pinned to the sidebar (persisted to the
|
||||
* account). Rendered entirely from the catalog registry.
|
||||
*/
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Star, Lock, ArrowRight, BookOpen } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { config } from '~/config'
|
||||
import { visibleCatalogByCategory, type CatalogEntry } from '~/lib/products/registry'
|
||||
import { openProduct } from '~/lib/products/open'
|
||||
import { useFavorites } from '~/lib/products/favorites'
|
||||
import { useIsGlobalAdmin } from '~/lib/auth/admin'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { FadeIn } from '~/components/ui/FadeIn'
|
||||
import OverviewDashboard from '~/components/products/OverviewModule'
|
||||
|
||||
function ProductCard({
|
||||
entry,
|
||||
pinned,
|
||||
onOpen,
|
||||
onToggle,
|
||||
onLearnMore,
|
||||
}: {
|
||||
entry: CatalogEntry
|
||||
pinned: boolean
|
||||
onOpen: () => void
|
||||
onToggle: () => void
|
||||
onLearnMore: () => void
|
||||
}) {
|
||||
const Icon = entry.icon
|
||||
return (
|
||||
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$3" width={272}>
|
||||
<XStack justify="space-between" items="flex-start">
|
||||
<XStack gap="$2" items="center" flex={1}>
|
||||
<Icon size={20} />
|
||||
<YStack flex={1}>
|
||||
<Text fontSize="$5" fontWeight="700">
|
||||
{entry.label}
|
||||
</Text>
|
||||
{entry.gcp ? (
|
||||
<Text fontSize="$1" color="$color10">
|
||||
{entry.gcp}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
</XStack>
|
||||
<XStack gap="$1" items="center">
|
||||
{entry.admin ? <Lock size={13} opacity={0.45} /> : null}
|
||||
<Button
|
||||
size="$2"
|
||||
chromeless
|
||||
opacity={pinned ? 1 : 0.3}
|
||||
icon={<Star size={15} />}
|
||||
onPress={onToggle}
|
||||
aria-label={pinned ? `Unpin ${entry.label}` : `Pin ${entry.label}`}
|
||||
/>
|
||||
</XStack>
|
||||
</XStack>
|
||||
|
||||
<Text fontSize="$3" color="$color11" minH={40}>
|
||||
{entry.description}
|
||||
</Text>
|
||||
|
||||
<XStack justify="space-between" items="center">
|
||||
<Button
|
||||
size="$2"
|
||||
chromeless
|
||||
icon={<BookOpen size={14} />}
|
||||
onPress={onLearnMore}
|
||||
aria-label={`Learn more about ${entry.label}`}
|
||||
>
|
||||
Learn more
|
||||
</Button>
|
||||
<Button
|
||||
size="$2"
|
||||
bg="$color5"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
onPress={onOpen}
|
||||
iconAfter={<ArrowRight size={14} />}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
</XStack>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DashboardHome() {
|
||||
const router = useRouter()
|
||||
const { toggle, isPinned } = useFavorites()
|
||||
const showAdmin = useIsGlobalAdmin()
|
||||
const push = (path: string) => router.push(path)
|
||||
const groups = visibleCatalogByCategory(showAdmin)
|
||||
|
||||
return (
|
||||
<YStack gap="$7">
|
||||
<OverviewDashboard />
|
||||
<YStack gap="$4">
|
||||
<PageHeader
|
||||
title="Explore products"
|
||||
subtitle={`Open and manage every ${config.brandName} product from one place.`}
|
||||
/>
|
||||
{groups.map((group, i) => (
|
||||
<FadeIn key={group.category} index={i} style={{ width: '100%' }}>
|
||||
<YStack gap="$3">
|
||||
<Text fontSize="$5" fontWeight="800" color="$color12">
|
||||
{group.category}
|
||||
</Text>
|
||||
<XStack flexWrap="wrap" gap="$3">
|
||||
{group.entries.map((entry) => (
|
||||
<ProductCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
pinned={isPinned(entry.id)}
|
||||
onOpen={() => openProduct(entry, push)}
|
||||
onToggle={() => toggle(entry.id)}
|
||||
onLearnMore={() => push(`/discover/${entry.id}`)}
|
||||
/>
|
||||
))}
|
||||
</XStack>
|
||||
</YStack>
|
||||
</FadeIn>
|
||||
))}
|
||||
</YStack>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Server-gated GLOBAL IAM admin proxy — cross-tenant IAM ops (any org).
|
||||
*
|
||||
* The browser holds no IAM credential. It calls this SAME-ORIGIN route with just
|
||||
* its session cookie; the handler enforces the GLOBAL admin gate (`getAdminGate`:
|
||||
* verified @<adminDomain> email AND a global-admin flag), then the shared
|
||||
* `forwardIam` applies the allow-list + tenant scoping (a global admin may act on
|
||||
* any org) and forwards to IAM as the user. A CUSTOMER managing their OWN org uses
|
||||
* `/org/iam` instead — this route is global-only.
|
||||
*
|
||||
* Least privilege: only an explicit allow-list of admin segments is reachable
|
||||
* (GET reads / POST mutations); every owner the request references — including
|
||||
* the mutation BODY owner — is validated by `forwardIam`.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAdminGate } from '~/lib/server/identity'
|
||||
import { forwardIam } from '~/lib/server/iam-proxy'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
/** Read segments — reachable via GET only. */
|
||||
const GET_SEGMENTS = new Set([
|
||||
'get-organizations',
|
||||
'get-organization',
|
||||
'get-users',
|
||||
'get-user',
|
||||
'get-applications',
|
||||
'get-application',
|
||||
'get-providers',
|
||||
'get-provider',
|
||||
'get-roles',
|
||||
'get-records',
|
||||
])
|
||||
|
||||
/** Mutation segments — reachable via POST only (JSON body forwarded). */
|
||||
const POST_SEGMENTS = new Set([
|
||||
'add-user',
|
||||
'update-user',
|
||||
'delete-user',
|
||||
'add-application',
|
||||
'update-application',
|
||||
'delete-application',
|
||||
'add-provider',
|
||||
'update-provider',
|
||||
'delete-provider',
|
||||
])
|
||||
|
||||
/**
|
||||
* Organization objects are owned by IAM's built-in `admin`, and the org
|
||||
* list/get endpoints scope results to the caller's org server-side — so `admin`
|
||||
* is an acceptable owner THERE (never for tenant data like users/roles).
|
||||
*/
|
||||
const ORG_ENDPOINTS = new Set(['get-organizations', 'get-organization'])
|
||||
|
||||
const forbidden = () => NextResponse.json({ error: 'forbidden' }, { status: 403 })
|
||||
|
||||
async function handle(req: NextRequest, path: string[], method: 'GET' | 'POST'): Promise<NextResponse> {
|
||||
const gate = await getAdminGate(req)
|
||||
if (!gate) return forbidden()
|
||||
return forwardIam(
|
||||
req,
|
||||
{ user: gate.user, isGlobalAdmin: gate.user.isGlobalAdmin, orgScope: gate.orgScope },
|
||||
{
|
||||
segment: path.join('/'),
|
||||
method,
|
||||
allowed: method === 'GET' ? GET_SEGMENTS : POST_SEGMENTS,
|
||||
orgMetaSegments: ORG_ENDPOINTS,
|
||||
// The gate is already global-only; global admins may write to any org.
|
||||
requireAdminForWrite: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, (await ctx.params).path, 'GET')
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, (await ctx.params).path, 'POST')
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Server-gated KMS admin proxy — the ONLY way the browser reaches Hanzo KMS.
|
||||
*
|
||||
* Same trust boundary as the IAM proxy: the browser sends only its session
|
||||
* cookie, this handler enforces the brand-admin gate, then forwards to kmsd as
|
||||
* the user (short-lived user-bound bearer) so KMS enforces org isolation from the
|
||||
* verified `owner` claim (`canActOnOrg`). Secrets are scoped to the brand org by
|
||||
* default; a global admin may target another org with `?org=`.
|
||||
*
|
||||
* Zero-knowledge discipline: this route NEVER logs a secret value or any request
|
||||
* body, and never derives or stores key material — it is a faithful pass-through
|
||||
* of kmsd's JSON + status code. One resource path (`/admin/kms/secrets`); the
|
||||
* verb + query select the operation:
|
||||
* GET ?path=&name=&env= → reveal one value → GET .../secrets/<path>/<name>?env=
|
||||
* GET ?prefix=&env= → list metadata → GET .../secrets?prefix=&env=
|
||||
* POST {path,name,env,value} → create/upsert → POST .../secrets
|
||||
* PATCH ?path=&name= {value,version,env} → rotate → PATCH .../secrets/<path>/<name>
|
||||
* DELETE ?path=&name=&env= → delete → DELETE .../secrets/<path>/<name>?env=
|
||||
*
|
||||
* kmsd has no list endpoint yet — the list GET returns 404, which the KMS module
|
||||
* renders as an honest "listing requires kmsd ≥ next release" state.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAdminGate, adminBearer, kmsBaseUrl, type AdminGate } from '~/lib/server/identity'
|
||||
import { orgFor as policyOrgFor } from '~/lib/server/admin-policy'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const forbidden = () => NextResponse.json({ error: 'forbidden' }, { status: 403 })
|
||||
const notFound = () => NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||
|
||||
/** `<path>/<name>` for the kmsd route, each segment encoded, slashes preserved. */
|
||||
function secretRest(path: string, name: string): string {
|
||||
return [...path.split('/').filter(Boolean), name].map(encodeURIComponent).join('/')
|
||||
}
|
||||
|
||||
/** Org the operator acts on — the brand org, unless a global admin passes ?org=
|
||||
* (the pure `admin-policy` predicate, tested in admin-policy.test.ts). */
|
||||
function orgFor(gate: AdminGate, req: NextRequest): string {
|
||||
return policyOrgFor(
|
||||
{ isGlobalAdmin: gate.user.isGlobalAdmin, orgScope: gate.orgScope },
|
||||
req.nextUrl.searchParams.get('org'),
|
||||
)
|
||||
}
|
||||
|
||||
async function handle(req: NextRequest, segments: string[]): Promise<NextResponse> {
|
||||
const gate = await getAdminGate(req)
|
||||
if (!gate) return forbidden()
|
||||
if (segments.length !== 1 || segments[0] !== 'secrets') return notFound()
|
||||
|
||||
const org = orgFor(gate, req)
|
||||
const base = `${kmsBaseUrl()}/v1/kms/orgs/${encodeURIComponent(org)}/secrets`
|
||||
const q = req.nextUrl.searchParams
|
||||
const name = q.get('name') ?? ''
|
||||
const path = q.get('path') ?? ''
|
||||
const env = q.get('env') ?? ''
|
||||
|
||||
let target: string
|
||||
let body: string | undefined
|
||||
if (req.method === 'GET') {
|
||||
if (name) {
|
||||
const params = new URLSearchParams()
|
||||
if (env) params.set('env', env)
|
||||
target = `${base}/${secretRest(path, name)}${params.toString() ? `?${params}` : ''}`
|
||||
} else {
|
||||
const params = new URLSearchParams()
|
||||
const prefix = q.get('prefix')
|
||||
if (prefix) params.set('prefix', prefix)
|
||||
if (env) params.set('env', env)
|
||||
target = `${base}${params.toString() ? `?${params}` : ''}`
|
||||
}
|
||||
} else if (req.method === 'POST') {
|
||||
target = base
|
||||
body = await req.text() // {path,name,env,value} — forwarded verbatim, never logged
|
||||
} else if (req.method === 'PATCH') {
|
||||
if (!name) return notFound()
|
||||
target = `${base}/${secretRest(path, name)}`
|
||||
body = await req.text() // {value,version,env} — forwarded verbatim, never logged
|
||||
} else if (req.method === 'DELETE') {
|
||||
if (!name) return notFound()
|
||||
const params = new URLSearchParams()
|
||||
if (env) params.set('env', env)
|
||||
target = `${base}/${secretRest(path, name)}${params.toString() ? `?${params}` : ''}`
|
||||
} else {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
let bearer: string
|
||||
try {
|
||||
bearer = await adminBearer(gate.user)
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ message: `Could not authorize the request: ${e instanceof Error ? e.message : String(e)}` },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { Authorization: `Bearer ${bearer}`, Accept: 'application/json' }
|
||||
const init: RequestInit = { method: req.method, headers, cache: 'no-store' }
|
||||
if (body !== undefined) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
init.body = body
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(target, init)
|
||||
const text = await res.text()
|
||||
return new NextResponse(text, {
|
||||
status: res.status,
|
||||
headers: { 'Content-Type': res.headers.get('content-type') ?? 'application/json' },
|
||||
})
|
||||
} catch (e) {
|
||||
// Surface only the transport failure — never the request body/value.
|
||||
return NextResponse.json(
|
||||
{ message: `KMS unreachable: ${e instanceof Error ? e.message : String(e)}` },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, (await ctx.params).path)
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, (await ctx.params).path)
|
||||
}
|
||||
export async function PATCH(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, (await ctx.params).path)
|
||||
}
|
||||
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, (await ctx.params).path)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Keyless AI proxy — the ONE path the console uses to reach the model gateway.
|
||||
*
|
||||
* `/v1/chat/completions` (and friends) REQUIRE an `Authorization: Bearer` token;
|
||||
* a browser session cookie alone is rejected. Rather than ship the user's durable
|
||||
* `hk-` key to the browser, the console calls its OWN origin (`/ai/v1/...`) with
|
||||
* just the session cookie; this server handler resolves the user, mints a
|
||||
* SHORT-LIVED, user-bound IAM token (issue-user-token, cached until just before
|
||||
* expiry), and forwards to the gateway with that token. No key in the browser, no
|
||||
* rotation on a chat turn, and every call is billed to the user's own org.
|
||||
*
|
||||
* Least privilege: only the read/inference AI endpoints are proxied; anything
|
||||
* else 404s, so this is not a general gateway tunnel.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { resolveUser, issueUserToken, type SessionUser } from '~/lib/server/identity'
|
||||
import { retrievalHeaders } from '~/lib/server/ai-proxy'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
/** Gateway the proxied AI calls are forwarded to (gated/priced api.hanzo.ai). */
|
||||
const AI_GATEWAY_URL = trim(process.env.AI_GATEWAY_URL ?? 'https://api.hanzo.ai')
|
||||
|
||||
/** The exact `/v1/<...>` endpoints the console is allowed to reach. */
|
||||
const ALLOWED = new Set([
|
||||
'v1/models',
|
||||
'v1/pricing/models', // the rich model+provider catalog (context, pricing, specs, tier) for Models/Providers pages
|
||||
'v1/plans', // the subscription tiers + entitlements (rpm/tpm/quota) for the catalog plan badges
|
||||
'v1/chat',
|
||||
'v1/chat/completions',
|
||||
'v1/embeddings',
|
||||
'v1/rerank',
|
||||
'v1/audio/speech', // text-to-speech (JSON in → audio bytes out) for the Playground Audio tab
|
||||
])
|
||||
|
||||
// ── Short-lived user-token cache ─────────────────────────────────────────────
|
||||
// issue-user-token is an IAM round-trip; cache the JWT per user until ~60s before
|
||||
// it expires so a chat session reuses one token instead of issuing per turn.
|
||||
type CachedToken = { token: string; expMs: number }
|
||||
const tokenCache = new Map<string, CachedToken>()
|
||||
const SKEW_MS = 60_000
|
||||
const FALLBACK_TTL_MS = 5 * 60_000
|
||||
|
||||
async function tokenFor(user: SessionUser): Promise<string> {
|
||||
const hit = tokenCache.get(user.id)
|
||||
if (hit && hit.expMs > Date.now()) return hit.token
|
||||
const { accessToken, expiresIn } = await issueUserToken(user)
|
||||
const ttl = expiresIn > 0 ? expiresIn * 1000 : FALLBACK_TTL_MS
|
||||
tokenCache.set(user.id, { token: accessToken, expMs: Date.now() + ttl - SKEW_MS })
|
||||
return accessToken
|
||||
}
|
||||
|
||||
async function forward(req: NextRequest, path: string[]): Promise<NextResponse> {
|
||||
const rel = path.join('/')
|
||||
if (!ALLOWED.has(rel)) {
|
||||
return NextResponse.json({ error: { message: 'Not found', type: 'not_found' } }, { status: 404 })
|
||||
}
|
||||
|
||||
const user = await resolveUser(req)
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: { message: 'Sign in to use AI.', type: 'auth_error', code: 'unauthenticated' } },
|
||||
{ status: 401 },
|
||||
)
|
||||
}
|
||||
|
||||
let token: string
|
||||
try {
|
||||
token = await tokenFor(user)
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: { message: `Could not authorize the request: ${e instanceof Error ? e.message : String(e)}`, type: 'auth_error' } },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
|
||||
const url = `${AI_GATEWAY_URL}/${rel}${req.nextUrl.search}`
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
// The gateway derives org/user from the JWT; pass the resolved org too so a
|
||||
// pooled-org backend scopes correctly either way (it strips client headers).
|
||||
'X-Org-Id': user.owner,
|
||||
}
|
||||
// Forward the RAG retrieval switch when present (allow-listed in `ai-proxy`).
|
||||
// chat/completions turns on built-in retrieval from `X-Retrieval`/
|
||||
// `X-Retrieval-Store` (backend controllers/chat_retrieval.go); this handler
|
||||
// rebuilds headers from scratch, so without the passthrough `AiApi.ragChat`
|
||||
// silently degraded to a plain answer. The store's org owner is still resolved
|
||||
// server-side from the session.
|
||||
Object.assign(headers, retrievalHeaders((h) => req.headers.get(h)))
|
||||
const init: RequestInit = {
|
||||
method: req.method,
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
// Propagate a client abort (Stop / tab-switch / unmount) through to the
|
||||
// gateway, so cancelling a stream stops upstream generation + billing and
|
||||
// releases the socket — not just the browser→proxy hop.
|
||||
signal: req.signal,
|
||||
}
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
init.body = await req.text()
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(url, init)
|
||||
// Stream the upstream body straight through — do NOT buffer with res.text().
|
||||
// chat/completions with `stream:true` returns Server-Sent Events, and the
|
||||
// multi-model compare playground measures real time-to-first-token from the
|
||||
// first streamed chunk; buffering would collapse TTFT into total latency.
|
||||
// Passing res.body through is equally correct for the non-streaming JSON
|
||||
// callers (they read the full body) and for binary audio/speech bytes — one
|
||||
// passthrough serves every allow-listed endpoint, streaming or not.
|
||||
return new NextResponse(res.body, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
'Content-Type': res.headers.get('content-type') ?? 'application/json',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: { message: `AI gateway unreachable: ${e instanceof Error ? e.message : String(e)}`, type: 'upstream_error' } },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return forward(req, (await ctx.params).path)
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return forward(req, (await ctx.params).path)
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* IAM OAuth callback. IAM redirects here with `?code&state`; we exchange them
|
||||
* for a backend session (`/v1/signin`) and land on the dashboard. On failure we
|
||||
* surface the error and offer a retry.
|
||||
*/
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Button, Text, YStack } from '@hanzo/gui'
|
||||
|
||||
import { ApiError } from '~/lib/api'
|
||||
import { Loader } from '~/components/ui/Loader'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
|
||||
function Callback() {
|
||||
const params = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { completeSignIn } = useSession()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const code = params.get('code')
|
||||
const state = params.get('state')
|
||||
if (!code || !state) {
|
||||
setError('Missing authorization code.')
|
||||
return
|
||||
}
|
||||
completeSignIn(code, state)
|
||||
.then(() => router.replace('/'))
|
||||
.catch((e: unknown) => setError(e instanceof ApiError ? e.message : 'Sign-in failed.'))
|
||||
}, [params, completeSignIn, router])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<YStack flex={1} minH="100vh" items="center" justify="center" gap="$3">
|
||||
<Text color="$color12" fontWeight="600">
|
||||
{error}
|
||||
</Text>
|
||||
<Button onPress={() => router.replace('/signin')}>Back to sign in</Button>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
return <Loader label="Completing sign-in…" />
|
||||
}
|
||||
|
||||
export default function CallbackPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<Callback />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Per-tenant billing proxy → commerce. The browser calls console2's OWN origin
|
||||
* (`/billing/...`); this server handler forwards to commerce's `/v1/billing/...`,
|
||||
* injecting the commerce SERVICE token from server-only env (never `NEXT_PUBLIC_`,
|
||||
* never in the browser bundle) AND scoping every request to the caller's OWN org.
|
||||
*
|
||||
* Same trust boundary as the `/admin/iam` + `/admin/kms` proxies, but the authz is
|
||||
* PER-TENANT, not admin: any authenticated session may read/act on ITS OWN billing
|
||||
* (balance / usage / invoices / credit-grants). The org is resolved server-side
|
||||
* from the validated session (`resolveUser`) and stamped as `X-Hanzo-Org` + the
|
||||
* `user` billing-subject — the client CANNOT widen scope (a supplied `?user=`/`?org=`
|
||||
* is overwritten), so commerce's per-org isolation can never be crossed from the
|
||||
* browser. No session → 401.
|
||||
*
|
||||
* Billing-subject mirrors `object.BillingSubject` (hanzoai/ai) + chat's
|
||||
* `billingSubject`: a member of a PERSONAL-billing org (default the shared `hanzo`
|
||||
* catch-all) bills per-user as `<org>/<name>`; a dedicated org (maxpower, …) bills
|
||||
* per-org as `<org>`. The SAME subject the gateway debits — so the console shows
|
||||
* the exact balance/usage that gets charged.
|
||||
*
|
||||
* `COMMERCE_TOKEN` unset → honest 501 (the UI shows a truthful "not configured"
|
||||
* state; it never fabricates a balance).
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { resolveUser } from '~/lib/server/identity'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const isSafeSegment = (s: string): boolean =>
|
||||
s.length > 0 && s !== '.' && s !== '..' && !s.includes('/') && !s.includes('\\') && !s.includes('\0')
|
||||
|
||||
/** Orgs whose members bill per-USER (the shared catch-all). Mirrors PERSONAL_BILLING_ORGS. */
|
||||
function personalBillingOrgs(): Set<string> {
|
||||
const raw = (process.env.PERSONAL_BILLING_ORGS || process.env.HANZO_DEFAULT_ORG || 'hanzo')
|
||||
.split(',')
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
return new Set(raw)
|
||||
}
|
||||
|
||||
/** Commerce billing subject for an org+user (= the subject the gateway debits). */
|
||||
function billingSubject(org: string, name: string): string {
|
||||
const o = org.trim().toLowerCase()
|
||||
if (!o) return ''
|
||||
if (personalBillingOrgs().has(o)) {
|
||||
const n = name.trim().toLowerCase()
|
||||
return n ? `${o}/${n}` : o
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
function commerceBaseUrl(): string {
|
||||
return (process.env.COMMERCE_URL ?? 'http://commerce.hanzo.svc:8001').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
async function forward(req: NextRequest, path: string[]): Promise<NextResponse> {
|
||||
// Per-tenant authz: any valid session may see ITS OWN billing (no admin gate).
|
||||
const user = await resolveUser(req)
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Sign in to view billing.' }, { status: 401 })
|
||||
}
|
||||
if (!path.every(isSafeSegment)) {
|
||||
return NextResponse.json({ error: 'Invalid billing path.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const token = process.env.COMMERCE_TOKEN ?? process.env.COMMERCE_SERVICE_TOKEN ?? ''
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Billing is not configured (COMMERCE_TOKEN missing).' },
|
||||
{ status: 501 },
|
||||
)
|
||||
}
|
||||
|
||||
// Scope to the caller's OWN org — server-resolved, never client-supplied.
|
||||
const org = user.owner.trim()
|
||||
const subject = billingSubject(org, user.name)
|
||||
|
||||
// Overwrite any client `user`/`org` so the browser cannot read another tenant's ledger.
|
||||
const search = new URLSearchParams(req.nextUrl.search)
|
||||
search.set('user', subject)
|
||||
search.delete('org')
|
||||
const qs = search.toString()
|
||||
const url = `${commerceBaseUrl()}/v1/billing/${path.join('/')}${qs ? `?${qs}` : ''}`
|
||||
|
||||
const init: RequestInit = {
|
||||
method: req.method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'X-Hanzo-Org': org,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
}
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
init.body = await req.text()
|
||||
}
|
||||
try {
|
||||
const res = await fetch(url, init)
|
||||
const text = await res.text()
|
||||
return new NextResponse(text, {
|
||||
status: res.status,
|
||||
headers: { 'Content-Type': res.headers.get('content-type') ?? 'application/json' },
|
||||
})
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: `Billing upstream unreachable: ${e instanceof Error ? e.message : String(e)}` },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return forward(req, (await ctx.params).path)
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return forward(req, (await ctx.params).path)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Wallet HUSD top-up — verify on-chain, then record to commerce (server route).
|
||||
*
|
||||
* Why this lives in console2 and not billing: billing.hanzo.ai is a Next static
|
||||
* export (`output: 'export'`) and cannot host a runtime POST handler, and the
|
||||
* commerce backend is owned elsewhere. So the verify-and-record seam lives here
|
||||
* as a same-origin server route — the same pattern as `app/paas/[...path]`: the
|
||||
* browser calls the console's OWN origin, the server does the privileged work,
|
||||
* and config comes from server-only env (sourced via KMS, never `NEXT_PUBLIC`).
|
||||
*
|
||||
* Flow: the client sends an HUSD ERC-20 transfer to the treasury and posts the
|
||||
* tx hash here. We read the receipt from the Hanzo EVM, confirm it is a mined,
|
||||
* successful HUSD `Transfer(from → treasury, value)`, derive USD cents from the
|
||||
* (18-decimal, USD-pegged) value, then record it to commerce as a `husd` crypto
|
||||
* payment and return the credited amount + the new balance. The on-chain amount
|
||||
* — never a client-supplied number — is what gets credited.
|
||||
*
|
||||
* Honest failure: if HUSD/treasury are unconfigured (greenfield — HUSD not yet
|
||||
* deployed) we return 501 so the UI shows a truthful "coming" state; if the tx
|
||||
* is missing/failed/not an HUSD-to-treasury transfer we return 400; if the chain
|
||||
* or commerce is unreachable we return 502. Never a fabricated credit.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { ethers } from 'ethers'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const RPC_URL = (process.env.HANZO_RPC_URL ?? 'https://rpc.hanzo.network').replace(/\/+$/, '')
|
||||
const HUSD_ADDRESS = (process.env.HANZO_HUSD_ADDRESS ?? '').trim()
|
||||
const TREASURY = (process.env.HANZO_HUSD_TREASURY ?? '').trim()
|
||||
const COMMERCE_URL = (process.env.COMMERCE_URL ?? 'https://api.hanzo.ai').replace(/\/+$/, '')
|
||||
const CHAIN_ID = Number(process.env.HANZO_CHAIN_ID ?? '36900')
|
||||
|
||||
const ERC20_TRANSFER_ABI = ['event Transfer(address indexed from, address indexed to, uint256 value)']
|
||||
const isAddr = (a: string): boolean => /^0x[0-9a-fA-F]{40}$/.test(a)
|
||||
|
||||
/** Forward the caller's identity (session cookie / bearer) to commerce. */
|
||||
function authHeaders(req: NextRequest): Record<string, string> {
|
||||
const h: Record<string, string> = { 'Content-Type': 'application/json', Accept: 'application/json' }
|
||||
const cookie = req.headers.get('cookie')
|
||||
if (cookie) h.Cookie = cookie
|
||||
const auth = req.headers.get('authorization')
|
||||
if (auth) h.Authorization = auth
|
||||
return h
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
// Greenfield gate: no HUSD contract / treasury ⇒ honest "not configured".
|
||||
if (!isAddr(HUSD_ADDRESS) || !isAddr(TREASURY)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'HUSD top-up is not configured yet (HUSD is not deployed on Hanzo Mainnet).' },
|
||||
{ status: 501 },
|
||||
)
|
||||
}
|
||||
|
||||
let body: { txHash?: string; fromAddress?: string; userId?: string }
|
||||
try {
|
||||
body = await req.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const txHash = (body.txHash ?? '').trim()
|
||||
const fromAddress = (body.fromAddress ?? '').trim()
|
||||
if (!/^0x[0-9a-fA-F]{64}$/.test(txHash)) {
|
||||
return NextResponse.json({ error: 'A valid transaction hash is required.' }, { status: 400 })
|
||||
}
|
||||
|
||||
// ── 1. Verify the HUSD transfer on-chain ────────────────────────────────────
|
||||
let creditedCents: number
|
||||
let verifiedFrom: string
|
||||
try {
|
||||
const provider = new ethers.JsonRpcProvider(RPC_URL, CHAIN_ID)
|
||||
const receipt = await provider.getTransactionReceipt(txHash)
|
||||
if (!receipt) {
|
||||
return NextResponse.json({ error: 'Transaction not found or not yet mined.' }, { status: 400 })
|
||||
}
|
||||
if (receipt.status !== 1) {
|
||||
return NextResponse.json({ error: 'Transaction failed on-chain.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const iface = new ethers.Interface(ERC20_TRANSFER_ABI)
|
||||
const husd = HUSD_ADDRESS.toLowerCase()
|
||||
const treasury = TREASURY.toLowerCase()
|
||||
let value: bigint | null = null
|
||||
for (const log of receipt.logs) {
|
||||
if (log.address.toLowerCase() !== husd) continue
|
||||
let parsed: ethers.LogDescription | null = null
|
||||
try {
|
||||
parsed = iface.parseLog({ topics: [...log.topics], data: log.data })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (parsed?.name !== 'Transfer') continue
|
||||
if (String(parsed.args.to).toLowerCase() !== treasury) continue
|
||||
value = parsed.args.value as bigint
|
||||
verifiedFrom = ethers.getAddress(String(parsed.args.from))
|
||||
break
|
||||
}
|
||||
if (value === null) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No HUSD transfer to the treasury was found in this transaction.' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
if (fromAddress && isAddr(fromAddress) && verifiedFrom!.toLowerCase() !== fromAddress.toLowerCase()) {
|
||||
return NextResponse.json({ error: 'Transfer sender does not match the connected wallet.' }, { status: 400 })
|
||||
}
|
||||
// HUSD is an 18-decimal, USD-pegged stablecoin → 1e16 base units = 1 cent.
|
||||
creditedCents = Number(value / 10n ** 16n)
|
||||
if (creditedCents <= 0) {
|
||||
return NextResponse.json({ error: 'Transferred amount is below the minimum (1 cent).' }, { status: 400 })
|
||||
}
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: `Could not verify the transaction on Hanzo Mainnet: ${e instanceof Error ? e.message : String(e)}` },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
|
||||
// ── 2. Record to commerce as an HUSD crypto payment ─────────────────────────
|
||||
try {
|
||||
const recordRes = await fetch(`${COMMERCE_URL}/v1/billing/payment`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(req),
|
||||
cache: 'no-store',
|
||||
body: JSON.stringify({
|
||||
method: 'crypto',
|
||||
network: 'hanzo',
|
||||
chainId: CHAIN_ID,
|
||||
currency: 'husd',
|
||||
amount: creditedCents,
|
||||
txHash,
|
||||
fromAddress: verifiedFrom!,
|
||||
toAddress: TREASURY,
|
||||
userId: body.userId,
|
||||
}),
|
||||
})
|
||||
if (!recordRes.ok) {
|
||||
const text = await recordRes.text().catch(() => '')
|
||||
return NextResponse.json(
|
||||
{ error: `Commerce rejected the payment (HTTP ${recordRes.status}): ${text}`.trim() },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
const payment = (await recordRes.json().catch(() => ({}))) as { status?: string }
|
||||
|
||||
// New balance (USD ledger) — best-effort; the credit already landed.
|
||||
let balance = 0
|
||||
try {
|
||||
const balRes = await fetch(
|
||||
`${COMMERCE_URL}/v1/billing/balance?user=${encodeURIComponent(body.userId ?? '')}¤cy=usd`,
|
||||
{ headers: authHeaders(req), cache: 'no-store' },
|
||||
)
|
||||
if (balRes.ok) balance = ((await balRes.json()) as { balance?: number }).balance ?? 0
|
||||
} catch {
|
||||
/* balance is informational; the credit is recorded */
|
||||
}
|
||||
|
||||
return NextResponse.json({ creditedCents, balance, txHash, status: payment.status ?? 'recorded' })
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: `Could not reach commerce to record the payment: ${e instanceof Error ? e.message : String(e)}` },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* `/docs` → the brand documentation site (new origin), server-side redirect.
|
||||
*
|
||||
* Docs are an EXTERNAL product on their own domain (docs.hanzo.ai / docs.lux.network
|
||||
* / …), never an in-app route — so a typed or bookmarked `console.<brand>/docs`
|
||||
* must land on the real docs, not the catch-all `notFound()` (the old 404). The
|
||||
* brand is resolved from the request Host so a lux/zoo console redirects to ITS
|
||||
* docs. The sidebar "Docs" entry and the header "?" open the same URL in a new tab;
|
||||
* this handler only covers the direct-navigation case.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { resolveConfig } from '~/config'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
export function GET(req: NextRequest) {
|
||||
const host = req.headers.get('host')
|
||||
return NextResponse.redirect(resolveConfig(host ?? undefined).docsUrl, 308)
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,64 @@
|
||||
html,
|
||||
body,
|
||||
#__next {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: var(--background, #0a0a0a);
|
||||
color: var(--color, #f5f5f5);
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Motion — a single fade-up entrance (matches the hanzo.ai marketing feel:
|
||||
~0.4s ease-out, small upward travel, staggered by the consumer). One place
|
||||
defines it; <FadeIn> applies the class + per-item delay. */
|
||||
@keyframes hz-fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.hz-fade-up {
|
||||
animation: hz-fade-up 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
/* Honor the user's reduced-motion preference — no entrance animation. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hz-fade-up {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shell chrome motion — the sidebar collapse (width) and the Linear-style
|
||||
two-level nav slide (transform). One place defines the easing; the shell
|
||||
applies the class. `className` forwards to the underlying DOM node on web, so
|
||||
the browser transitions the Gui-driven inline width/transform. */
|
||||
.hz-collapse {
|
||||
transition: width 220ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
will-change: width;
|
||||
}
|
||||
|
||||
.hz-slide {
|
||||
transition: transform 260ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hz-collapse,
|
||||
.hz-slide {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 67 67" role="img" aria-label="Hanzo">
|
||||
<style>path{fill:#000}@media (prefers-color-scheme:dark){path{fill:#fff}}</style>
|
||||
<path d="M22.21 67V44.6369H0V67H22.21Z"/>
|
||||
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z"/>
|
||||
<path d="M22.21 0H0V22.3184H22.21V0Z"/>
|
||||
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z"/>
|
||||
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 443 B |
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Cloud API key management — the per-user `hk-` credential, minted server-side.
|
||||
*
|
||||
* Same-origin (`/keys`): the browser sends only its first-party session cookie;
|
||||
* this server handler resolves the user from that cookie and calls IAM as the
|
||||
* confidential `hanzo-console` client on the user's behalf. The `hk-` secret is
|
||||
* returned to the browser ONLY at creation (POST) — standard show-once handling —
|
||||
* and is otherwise never echoed. Authorization is the user's own session: a
|
||||
* caller can only ever mint/revoke their OWN key (IAM binds the id to the
|
||||
* resolved session), never another tenant's.
|
||||
*
|
||||
* POST /keys → mint (or rotate) the key; returns { accessKey } once.
|
||||
* DELETE /keys → revoke the key (the old key stops working).
|
||||
* GET /keys → whether a key exists (no secret material).
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { resolveUser, mintUserKey, revokeUserKey, mintConfigured } from '~/lib/server/identity'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const unauthorized = () =>
|
||||
NextResponse.json({ error: 'Sign in to manage API keys.' }, { status: 401 })
|
||||
|
||||
const notConfigured = () =>
|
||||
NextResponse.json(
|
||||
{ error: 'API key minting is not configured on this deployment (IAM client unset).' },
|
||||
{ status: 501 },
|
||||
)
|
||||
|
||||
export async function GET(req: NextRequest): Promise<NextResponse> {
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return unauthorized()
|
||||
// No secret material on GET — only whether a key exists and its public prefix.
|
||||
const hasKey = Boolean(user.accessKey)
|
||||
return NextResponse.json({
|
||||
hasKey,
|
||||
keyPrefix: hasKey ? user.accessKey.slice(0, 11) : '',
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return unauthorized()
|
||||
if (!mintConfigured()) return notConfigured()
|
||||
try {
|
||||
const accessKey = await mintUserKey(user)
|
||||
return NextResponse.json({ accessKey })
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: `Could not mint an API key: ${e instanceof Error ? e.message : String(e)}` },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest): Promise<NextResponse> {
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return unauthorized()
|
||||
if (!mintConfigured()) return notConfigured()
|
||||
try {
|
||||
await revokeUserKey(user)
|
||||
return NextResponse.json({ ok: true })
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: `Could not revoke the API key: ${e instanceof Error ? e.message : String(e)}` },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import '@hanzogui/core/reset.css'
|
||||
import './globals.css'
|
||||
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { Provider } from '~/components/Provider'
|
||||
import { ChunkGuard } from '~/components/ChunkGuard'
|
||||
import { branding } from '~/config'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: branding.name,
|
||||
description: 'Unified admin console for Hanzo Cloud and all cloud products.',
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: '#0a0a0a',
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en" className="t_dark" style={{ backgroundColor: '#0a0a0a', colorScheme: 'dark' }} suppressHydrationWarning>
|
||||
<body style={{ margin: 0 }}>
|
||||
<ChunkGuard />
|
||||
<Provider>{children}</Provider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Org onboarding — create the signed-in user's organization, server-side.
|
||||
*
|
||||
* Same-origin (`/onboard`): the browser sends only its first-party session
|
||||
* cookie; this handler resolves the user from that cookie and acts as the
|
||||
* confidential `hanzo-console` client (allowlisted for IAM_ORG_ADMIN_APPS +
|
||||
* IAM_USER_ADMIN_APPS). It creates a customer organization and makes the user
|
||||
* that org's admin (IAM membership = the user's `owner`, so the cloud's
|
||||
* GetEffectiveOrg scopes everything to the new org once the user re-authenticates).
|
||||
*
|
||||
* Fail-closed + safe:
|
||||
* - 401 with no session; 501 when the IAM client is unwired.
|
||||
* - ONLY a zero-org user may onboard (an existing member would have to be MOVED
|
||||
* out of their current org, orphaning its data) — anyone who already belongs
|
||||
* to an org gets 409 and uses the OrgSwitcher instead.
|
||||
* - reserved names (brand/staff + system orgs) are refused (pure policy).
|
||||
*
|
||||
* POST /onboard { name } → create + join org `slugify(name)`.
|
||||
* POST /onboard { personal: true } → create + join a personal `<username>` org.
|
||||
* Returns { org, displayName }; the client then re-auths so the new JWT carries
|
||||
* the new owner.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import {
|
||||
resolveAuthenticatedUser,
|
||||
mintConfigured,
|
||||
getOrganization,
|
||||
createOrganization,
|
||||
moveUserToOrg,
|
||||
} from '~/lib/server/identity'
|
||||
import {
|
||||
isReservedOrg,
|
||||
personalOrgSlug,
|
||||
slugifyOrg,
|
||||
validateOrgName,
|
||||
MAX_ORG_SLUG,
|
||||
MIN_ORG_SLUG,
|
||||
} from '~/lib/server/onboarding'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
/** Title-case the base of a username for a personal org's display name. */
|
||||
function humanize(username: string): string {
|
||||
const base = (username.includes('@') ? username.slice(0, username.indexOf('@')) : username)
|
||||
.replace(/[._-]+/g, ' ')
|
||||
.trim()
|
||||
return base ? base.replace(/\b\w/g, (c) => c.toUpperCase()) : 'Personal'
|
||||
}
|
||||
|
||||
/** First free slug at/after `base` (`base`, `base-2`, …); null if all taken. */
|
||||
async function freeSlug(base: string): Promise<string | null> {
|
||||
for (let i = 1; i <= 20; i++) {
|
||||
const candidate = i === 1 ? base : `${base.slice(0, MAX_ORG_SLUG - 3)}-${i}`.replace(/-+/g, '-')
|
||||
if (candidate.length < MIN_ORG_SLUG || isReservedOrg(candidate)) continue
|
||||
if (!(await getOrganization(candidate))) return candidate
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
const user = await resolveAuthenticatedUser(req)
|
||||
if (!user) return NextResponse.json({ error: 'Sign in to create an organization.' }, { status: 401 })
|
||||
if (!mintConfigured()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Organization creation is not configured on this deployment (IAM client unset).' },
|
||||
{ status: 501 },
|
||||
)
|
||||
}
|
||||
|
||||
const body = (await req.json().catch(() => ({}))) as { name?: string; personal?: boolean }
|
||||
const personal = body.personal === true
|
||||
|
||||
// Two flows, keyed on whether the user already has a home org:
|
||||
// - FIRST-RUN (no owner): create + MOVE the user in as admin, so their JWT
|
||||
// carries the new owner and the cloud scopes everything to it.
|
||||
// - ADDITIONAL (owner set): create the org but do NOT move the user — a move
|
||||
// changes their IAM `owner` (stripping a global admin's status and orphaning
|
||||
// their current org). They reach the new org via the OrgSwitcher, which
|
||||
// re-scopes X-Org-Id without touching IAM membership. A personal-org request
|
||||
// from someone who already has an org is meaningless, so refuse it.
|
||||
const additional = Boolean(user.owner)
|
||||
if (additional && personal) {
|
||||
return NextResponse.json(
|
||||
{ error: 'You already have an organization. Name the new one explicitly.' },
|
||||
{ status: 409 },
|
||||
)
|
||||
}
|
||||
|
||||
let baseSlug: string
|
||||
let displayName: string
|
||||
if (personal) {
|
||||
baseSlug = personalOrgSlug(user.name) || slugifyOrg(user.email)
|
||||
if (!baseSlug || baseSlug.length < MIN_ORG_SLUG || isReservedOrg(baseSlug)) {
|
||||
baseSlug = `org-${slugifyOrg(user.name) || 'workspace'}`
|
||||
}
|
||||
displayName = humanize(user.name || user.email)
|
||||
} else {
|
||||
const v = validateOrgName(body.name ?? '')
|
||||
if (!v.ok) return NextResponse.json({ error: v.error }, { status: 400 })
|
||||
baseSlug = v.slug
|
||||
displayName = (body.name ?? '').trim()
|
||||
}
|
||||
|
||||
// Personal orgs auto-suffix to stay unique; an explicit name that's taken is an
|
||||
// honest conflict the user resolves by choosing another.
|
||||
let slug = baseSlug
|
||||
if (await getOrganization(slug)) {
|
||||
if (!personal) {
|
||||
return NextResponse.json({ error: `“${slug}” is taken. Choose a different name.` }, { status: 409 })
|
||||
}
|
||||
const free = await freeSlug(baseSlug)
|
||||
if (!free) return NextResponse.json({ error: 'Could not find an available name.' }, { status: 409 })
|
||||
slug = free
|
||||
}
|
||||
|
||||
try {
|
||||
await createOrganization({ name: slug, displayName, personal, sourceOwner: user.owner })
|
||||
// First-run only: make the zero-org user this org's admin. For an additional
|
||||
// org we deliberately do NOT move them (preserve their owner/admin + old org).
|
||||
if (!additional) await moveUserToOrg(user, slug)
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: `Could not create the organization: ${e instanceof Error ? e.message : String(e)}` },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ org: slug, displayName, additional })
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Server-gated SELF-SERVICE org member proxy — a CUSTOMER managing their OWN org.
|
||||
*
|
||||
* The `/admin/iam` proxy is GLOBAL-admin only, so a tenant org owner (e.g.
|
||||
* Dave/maxpower) could never manage their own members through it. This proxy
|
||||
* closes that: it admits ANY authenticated user with an org (`getOrgGate`), then
|
||||
* the shared `forwardIam`:
|
||||
* - scopes every reference (query `owner`, `id` owner, and the mutation BODY
|
||||
* owner) to the caller's OWN org — a global admin may cross, a customer never;
|
||||
* - guards get-organization by org NAME (no reading another org's settings);
|
||||
* - requires an ORG ADMIN for writes (invite / change-role / remove), while any
|
||||
* member may READ the roster.
|
||||
* IAM enforces its own checks on the user-bound bearer too — this is the matching,
|
||||
* fail-closed server gate, not the only one.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getOrgGate } from '~/lib/server/identity'
|
||||
import { forwardIam } from '~/lib/server/iam-proxy'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
/** Reads — any member of the org (own org only, unless global). */
|
||||
const GET_SEGMENTS = new Set(['get-users', 'get-user', 'get-roles', 'get-organization'])
|
||||
|
||||
/** Writes — org admin only, own org only (unless global). */
|
||||
const POST_SEGMENTS = new Set(['add-user', 'update-user', 'delete-user'])
|
||||
|
||||
/** get-organization's owner is the `admin` metadata org (name guarded separately). */
|
||||
const ORG_META = new Set(['get-organization'])
|
||||
/** Segments carrying an org NAME to pin to the caller's scope. */
|
||||
const ORG_NAME = new Set(['get-organization'])
|
||||
|
||||
const forbidden = () => NextResponse.json({ error: 'forbidden' }, { status: 403 })
|
||||
|
||||
async function handle(req: NextRequest, path: string[], method: 'GET' | 'POST'): Promise<NextResponse> {
|
||||
const gate = await getOrgGate(req)
|
||||
if (!gate) return forbidden()
|
||||
return forwardIam(req, gate, {
|
||||
segment: path.join('/'),
|
||||
method,
|
||||
allowed: method === 'GET' ? GET_SEGMENTS : POST_SEGMENTS,
|
||||
orgMetaSegments: ORG_META,
|
||||
orgNameSegments: ORG_NAME,
|
||||
requireAdminForWrite: true,
|
||||
})
|
||||
}
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, (await ctx.params).path, 'GET')
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, (await ctx.params).path, 'POST')
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Same-origin proxy to the platform.hanzo.ai control plane (Job 3 — embedded
|
||||
* PaaS). The browser calls console2's OWN origin (`/paas/...`); this server-side
|
||||
* handler forwards to `platform.hanzo.ai/v1/...`, injecting the service token
|
||||
* from server-only env (sourced via KMS — never `NEXT_PUBLIC_`, never in the
|
||||
* browser bundle). This is the real control-plane API, not an iframe stub.
|
||||
*
|
||||
* SECURITY: the forwarded token is a PLATFORM SERVICE token — full control-plane
|
||||
* authority, NOT tenant-scoped. So this route is gated to brand admins exactly
|
||||
* like the IAM/KMS admin proxies: `getAdminGate` resolves the caller from their
|
||||
* own session and requires a verified brand-admin (no gate → 403). Without this,
|
||||
* any authenticated browser could drive the whole control plane through the
|
||||
* service token. The gate is the control, NOT a deploy-time env toggle.
|
||||
*
|
||||
* When `PAAS_SERVICE_TOKEN` is unset the proxy returns an honest 501 so the UI
|
||||
* can show a truthful "not configured" state — it never fabricates apps/deploys.
|
||||
*
|
||||
* SCOPE: the browser stamps the active tenant path (X-Org-Id / X-Project-Id /
|
||||
* X-Environment) on every call. We forward it to the control plane so PaaS
|
||||
* resources scope by org → project → environment like the rest of the console —
|
||||
* but the ORG is re-resolved server-side through the admin policy (`orgFor`): a
|
||||
* global admin's switched org is honored, a brand admin is PINNED to their own,
|
||||
* so the forwarded X-Org-Id is authoritative and never the spoofable claim.
|
||||
* Project + environment are sub-scopes the admin picks WITHIN that org, passed
|
||||
* through verbatim.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAdminGate } from '~/lib/server/identity'
|
||||
import { orgFor as policyOrgFor } from '~/lib/server/admin-policy'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const PLATFORM_URL = (process.env.PLATFORM_URL ?? 'https://platform.hanzo.ai').replace(/\/+$/, '')
|
||||
const TOKEN = process.env.PAAS_SERVICE_TOKEN ?? ''
|
||||
|
||||
async function forward(req: NextRequest, path: string[]): Promise<NextResponse> {
|
||||
// Brand-admin gate FIRST — the service token below is control-plane god-mode.
|
||||
const gate = await getAdminGate(req)
|
||||
if (!gate) {
|
||||
return NextResponse.json({ error: 'forbidden' }, { status: 403 })
|
||||
}
|
||||
if (!TOKEN) {
|
||||
return NextResponse.json(
|
||||
{ error: 'PaaS control plane is not configured (PAAS_SERVICE_TOKEN missing).' },
|
||||
{ status: 501 },
|
||||
)
|
||||
}
|
||||
// Resolve the authoritative tenant path. Org: the admin policy honors a global
|
||||
// admin's switched org (the X-Org-Id the browser sends = currentOrg()) and pins
|
||||
// a brand admin to their own — so we forward the resolved org, never the raw
|
||||
// claim. Project + environment are sub-scopes within that org, forwarded as-is.
|
||||
const org = policyOrgFor(
|
||||
{ isGlobalAdmin: gate.user.isGlobalAdmin, orgScope: gate.orgScope },
|
||||
req.headers.get('X-Org-Id'),
|
||||
)
|
||||
const projectId = req.headers.get('X-Project-Id')
|
||||
const environment = req.headers.get('X-Environment')
|
||||
|
||||
const search = req.nextUrl.search
|
||||
const url = `${PLATFORM_URL}/v1/${path.join('/')}${search}`
|
||||
const init: RequestInit = {
|
||||
method: req.method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-Org-Id': org,
|
||||
...(projectId ? { 'X-Project-Id': projectId } : {}),
|
||||
...(environment ? { 'X-Environment': environment } : {}),
|
||||
},
|
||||
// Never cache control-plane reads.
|
||||
cache: 'no-store',
|
||||
}
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
init.body = await req.text()
|
||||
}
|
||||
try {
|
||||
const res = await fetch(url, init)
|
||||
const text = await res.text()
|
||||
return new NextResponse(text, {
|
||||
status: res.status,
|
||||
headers: { 'Content-Type': res.headers.get('content-type') ?? 'application/json' },
|
||||
})
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: `PaaS upstream unreachable: ${e instanceof Error ? e.message : String(e)}` },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return forward(req, (await ctx.params).path)
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return forward(req, (await ctx.params).path)
|
||||
}
|
||||
export async function PATCH(req: NextRequest, ctx: Ctx) {
|
||||
return forward(req, (await ctx.params).path)
|
||||
}
|
||||
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||
return forward(req, (await ctx.params).path)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
import { SignInForm } from '~/components/SignInForm'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
|
||||
export default function SignInPage() {
|
||||
const { account, loading } = useSession()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && account) router.replace('/')
|
||||
}, [loading, account, router])
|
||||
|
||||
return <SignInForm />
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Per-user proxy to the Hanzo Base control plane (base.hanzo.ai) — the embedded
|
||||
* Base module's ONE transport. The browser calls console2's OWN origin
|
||||
* (`/superbase/v1/...`) with just the session cookie; this server handler
|
||||
* resolves the user, mints a SHORT-LIVED user-bound IAM token, and forwards to
|
||||
* base.hanzo.ai with that token. No token ever reaches the browser, and the
|
||||
* SAME @hanzo/superbase-dashboard screens render here and standalone.
|
||||
*
|
||||
* NOT the PaaS pattern: PaaS forwards a god-mode SERVICE token and is gated to
|
||||
* brand admins. Base authorizes PER USER itself — the `tenants` collection's
|
||||
* `ListRule = "owner_iam_user = @request.auth.id"` and admin-only mutations are
|
||||
* enforced by Base against the forwarded user identity. So here we forward the
|
||||
* USER's own minted bearer (least privilege, tenant-scoped by Base), and the
|
||||
* only gate is "must be signed in" (resolveUser → 401). A non-admin simply sees
|
||||
* their own tenants and gets Base's 403 on a mutation — honest, not faked.
|
||||
*
|
||||
* Least privilege on the path too: only the `tenants` collection records surface
|
||||
* is proxied; anything else 404s, so this is not a general Base tunnel.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { resolveUser, issueUserToken, type SessionUser } from '~/lib/server/identity'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
/** The Base control plane the proxied calls are forwarded to. */
|
||||
const BASE_URL = trim(process.env.BASE_DASHBOARD_URL ?? 'https://base.hanzo.ai')
|
||||
|
||||
/** Only the tenants collection records surface (list/get/create/update/delete). */
|
||||
const RECORDS = 'v1/collections/tenants/records'
|
||||
function allowed(rel: string): boolean {
|
||||
return rel === RECORDS || rel.startsWith(`${RECORDS}/`)
|
||||
}
|
||||
|
||||
// ── Short-lived user-token cache (same shape as the AI/admin proxies) ─────────
|
||||
type CachedToken = { token: string; expMs: number }
|
||||
const tokenCache = new Map<string, CachedToken>()
|
||||
const SKEW_MS = 60_000
|
||||
const FALLBACK_TTL_MS = 5 * 60_000
|
||||
|
||||
async function tokenFor(user: SessionUser): Promise<string> {
|
||||
const hit = tokenCache.get(user.id)
|
||||
if (hit && hit.expMs > Date.now()) return hit.token
|
||||
const { accessToken, expiresIn } = await issueUserToken(user)
|
||||
const ttl = expiresIn > 0 ? expiresIn * 1000 : FALLBACK_TTL_MS
|
||||
tokenCache.set(user.id, { token: accessToken, expMs: Date.now() + ttl - SKEW_MS })
|
||||
return accessToken
|
||||
}
|
||||
|
||||
async function forward(req: NextRequest, path: string[]): Promise<NextResponse> {
|
||||
const rel = path.join('/')
|
||||
if (!allowed(rel)) {
|
||||
return NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const user = await resolveUser(req)
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Sign in to manage Base tenants.' }, { status: 401 })
|
||||
}
|
||||
|
||||
let token: string
|
||||
try {
|
||||
token = await tokenFor(user)
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: `Could not authorize the request: ${e instanceof Error ? e.message : String(e)}` },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
|
||||
const url = `${BASE_URL}/${rel}${req.nextUrl.search}`
|
||||
const init: RequestInit = {
|
||||
method: req.method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
}
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
init.body = await req.text()
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(url, init)
|
||||
const text = await res.text()
|
||||
return new NextResponse(text, {
|
||||
status: res.status,
|
||||
headers: { 'Content-Type': res.headers.get('content-type') ?? 'application/json' },
|
||||
})
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: `Base control plane unreachable: ${e instanceof Error ? e.message : String(e)}` },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return forward(req, (await ctx.params).path)
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return forward(req, (await ctx.params).path)
|
||||
}
|
||||
export async function PATCH(req: NextRequest, ctx: Ctx) {
|
||||
return forward(req, (await ctx.params).path)
|
||||
}
|
||||
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||
return forward(req, (await ctx.params).path)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Same-origin proxy to the durable task engine (hanzoai/tasks `tasksd`, the native
|
||||
* Temporal-style HTTP surface at `/v1/tasks/*`).
|
||||
*
|
||||
* `tasksd` runs `TASKSD_REQUIRE_IDENTITY=true`: it validates an IAM **Bearer JWT**
|
||||
* against the IAM JWKS, unconditionally STRIPS inbound `X-Org-Id`, and mints org +
|
||||
* user from the JWT claims (`owner`→org). So — like the `/ai` proxy — the console
|
||||
* calls its OWN origin (`/tasksd/...`) with just the session cookie; this server
|
||||
* handler resolves the signed-in user, mints a SHORT-LIVED, user-bound IAM token
|
||||
* (cached until just before expiry), and forwards it as the Bearer. No key in the
|
||||
* browser, and every read is org-scoped by the JWT server-side.
|
||||
*
|
||||
* READ-ONLY: only GET is proxied (the console never mutates workflows here), so
|
||||
* this is a scoped read tunnel to `/v1/tasks/*`, nothing else. When the engine is
|
||||
* unreachable (the public `tasks.hanzo.ai` TLS is not yet provisioned; the real
|
||||
* path is the in-cluster service) the fetch fails and the UI shows an honest
|
||||
* BackendStateCard — never fabricated workflows.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { resolveUser, issueUserToken, type SessionUser } from '~/lib/server/identity'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
/** The durable task engine. Public TLS is not live yet → default to the in-cluster
|
||||
* service (tasks.hanzo.svc:80 → tasksd :7243). Override with TASKS_URL. */
|
||||
const TASKS_URL = trim(process.env.TASKS_URL ?? 'http://tasks.hanzo.svc.cluster.local')
|
||||
|
||||
// ── Short-lived user-token cache (mirrors the /ai proxy) ─────────────────────
|
||||
type CachedToken = { token: string; expMs: number }
|
||||
const tokenCache = new Map<string, CachedToken>()
|
||||
const SKEW_MS = 60_000
|
||||
const FALLBACK_TTL_MS = 5 * 60_000
|
||||
|
||||
async function tokenFor(user: SessionUser): Promise<string> {
|
||||
const hit = tokenCache.get(user.id)
|
||||
if (hit && hit.expMs > Date.now()) return hit.token
|
||||
const { accessToken, expiresIn } = await issueUserToken(user)
|
||||
const ttl = expiresIn > 0 ? expiresIn * 1000 : FALLBACK_TTL_MS
|
||||
tokenCache.set(user.id, { token: accessToken, expMs: Date.now() + ttl - SKEW_MS })
|
||||
return accessToken
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }): Promise<NextResponse> {
|
||||
const rel = (await ctx.params).path.join('/')
|
||||
|
||||
const user = await resolveUser(req)
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Sign in to view tasks.', code: 401 }, { status: 401 })
|
||||
}
|
||||
|
||||
let token: string
|
||||
try {
|
||||
token = await tokenFor(user)
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: `Could not authorize the request: ${e instanceof Error ? e.message : String(e)}`, code: 502 },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
|
||||
const url = `${TASKS_URL}/v1/tasks/${rel}${req.nextUrl.search}`
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/json',
|
||||
// tasksd mints org from the JWT and strips this, but send the active org so
|
||||
// it is correct in any topology (a gateway that re-injects overwrites it).
|
||||
'X-Org-Id': user.owner,
|
||||
},
|
||||
cache: 'no-store',
|
||||
signal: req.signal,
|
||||
})
|
||||
const text = await res.text()
|
||||
return new NextResponse(text, {
|
||||
status: res.status,
|
||||
headers: { 'Content-Type': res.headers.get('content-type') ?? 'application/json' },
|
||||
})
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: `Task engine unreachable: ${e instanceof Error ? e.message : String(e)}`, code: 502 },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Same-origin proxy to the cloud ML/training surface on hanzoai/ai (`/v1/train/*`,
|
||||
* `/v1/ml/models`, and the fine-tuning broker `/v1/finetune/*`).
|
||||
*
|
||||
* The console's Training page calls its OWN origin (`/training/...`) with just the
|
||||
* first-party session cookie; this server handler resolves the signed-in user from
|
||||
* that cookie and forwards to the cloud backend's `/v1/...` surface, passing the
|
||||
* cookie through (the proven `get-account` server-to-server pattern in
|
||||
* lib/server/identity.ts) plus the active `X-Org-Id`. Training is a TENANT action —
|
||||
* any signed-in org user may run it — so this is user-scoped (resolveUser), NOT the
|
||||
* control-plane admin gate the `/paas` proxy uses. The cloud backend scopes by org
|
||||
* (GetEffectiveOrg / the X-Org-Id the plain-REST train sub-service requires), so a
|
||||
* caller can only ever touch their own org's jobs. `POST /v1/train/jobs` is
|
||||
* billing-gated by the live ResourceMeter and returns 402 on an unfunded org — that
|
||||
* status flows straight back so the UI can surface it honestly.
|
||||
*
|
||||
* Least privilege: only the explicit ML/training sub-paths are forwarded; anything
|
||||
* else 404s, so this is not a general backend tunnel. No secret ever reaches the
|
||||
* browser — the HuggingFace token (for private repos) is resolved from KMS
|
||||
* server-side inside the broker, never here.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { resolveUser } from '~/lib/server/identity'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
/** Cloud `/v1` backend (hanzoai/ai) — same target lib/server/identity.ts resolves. */
|
||||
const CLOUD_API_URL = trim(process.env.CLOUD_API_URL ?? 'http://cloud-api.hanzo.svc.cluster.local:8000')
|
||||
|
||||
/** The exact `/v1/<...>` ML/training sub-paths the console is allowed to reach. */
|
||||
const ALLOWED = new Set([
|
||||
// mlsvc — the canonical training surface (task #40 ResourceMeter gates POST jobs).
|
||||
'train/jobs',
|
||||
'train/experiments',
|
||||
'ml/models',
|
||||
// fine-tuning broker (custom-data runs, HF search) — sibling surface.
|
||||
'finetune/jobs',
|
||||
'finetune/job',
|
||||
'finetune/cancel',
|
||||
'finetune/deploy',
|
||||
'finetune/presets',
|
||||
'finetune/hf/models',
|
||||
'finetune/hf/datasets',
|
||||
'finetune/hf/repo',
|
||||
])
|
||||
|
||||
async function forward(req: NextRequest, path: string[]): Promise<NextResponse> {
|
||||
const rel = path.join('/')
|
||||
if (!ALLOWED.has(rel)) {
|
||||
return NextResponse.json({ status: 'error', msg: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const user = await resolveUser(req)
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ status: 'error', msg: 'Sign in to manage training.' },
|
||||
{ status: 401 },
|
||||
)
|
||||
}
|
||||
|
||||
const cookie = req.headers.get('cookie') ?? ''
|
||||
const url = `${CLOUD_API_URL}/v1/${rel}${req.nextUrl.search}`
|
||||
const headers: Record<string, string> = {
|
||||
cookie,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
// The backend re-validates org from the session; forward the active scope so a
|
||||
// global admin's switched org is honored and a brand admin is pinned to theirs.
|
||||
'X-Org-Id': req.headers.get('X-Org-Id') ?? user.owner,
|
||||
}
|
||||
const projectId = req.headers.get('X-Project-Id')
|
||||
const environment = req.headers.get('X-Environment')
|
||||
if (projectId) headers['X-Project-Id'] = projectId
|
||||
if (environment) headers['X-Environment'] = environment
|
||||
|
||||
const init: RequestInit = { method: req.method, headers, cache: 'no-store' }
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
init.body = await req.text()
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(url, init)
|
||||
const text = await res.text()
|
||||
return new NextResponse(text, {
|
||||
status: res.status,
|
||||
headers: { 'Content-Type': res.headers.get('content-type') ?? 'application/json' },
|
||||
})
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ status: 'error', msg: `Fine-tuning backend unreachable: ${e instanceof Error ? e.message : String(e)}` },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return forward(req, (await ctx.params).path)
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return forward(req, (await ctx.params).path)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Waitlist join — the "Join waitlist" CTA on coming-soon products, server-side.
|
||||
*
|
||||
* Same-origin (`/waitlist`): the browser posts only its session cookie + the
|
||||
* waitlist slug and email; this handler forwards to the Hanzo Base waitlist
|
||||
* plugin (`POST /v1/waitlist/join`, the hanzoai/waitlist pattern — a per-product
|
||||
* SQLite-backed list). The backend URL is server-only env `WAITLIST_URL` (never
|
||||
* NEXT_PUBLIC_); when it is unset the route returns an honest 501 and the form
|
||||
* says the waitlist isn't open yet — no fake confirmation.
|
||||
*
|
||||
* The console UI is auth-gated, so we require a session (a signed-in user) to
|
||||
* keep this proxy from being an open relay to the waitlist backend, and forward
|
||||
* the client IP so the backend's per-IP rate limit still applies.
|
||||
*
|
||||
* POST /waitlist { waitlist, email } → { ok, rank, total, alreadyJoined, … }
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { resolveUser } from '~/lib/server/identity'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const WAITLIST_URL = (process.env.WAITLIST_URL ?? '').replace(/\/+$/, '')
|
||||
|
||||
/** Minimal email sanity check — the backend is authoritative (disposable, etc.). */
|
||||
const looksLikeEmail = (s: string): boolean => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s)
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return NextResponse.json({ error: 'Sign in to join the waitlist.' }, { status: 401 })
|
||||
|
||||
if (!WAITLIST_URL) {
|
||||
return NextResponse.json(
|
||||
{ error: 'The waitlist is not open on this deployment yet.' },
|
||||
{ status: 501 },
|
||||
)
|
||||
}
|
||||
|
||||
const body = (await req.json().catch(() => ({}))) as { waitlist?: string; email?: string }
|
||||
const waitlist = (body.waitlist ?? '').trim()
|
||||
const email = (body.email ?? user.email ?? '').trim()
|
||||
if (!waitlist) return NextResponse.json({ error: 'Missing waitlist.' }, { status: 400 })
|
||||
if (!looksLikeEmail(email)) return NextResponse.json({ error: 'Enter a valid email.' }, { status: 400 })
|
||||
|
||||
const fwd =
|
||||
req.headers.get('x-forwarded-for') ?? req.headers.get('x-real-ip') ?? ''
|
||||
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(`${WAITLIST_URL}/v1/waitlist/join`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
...(fwd ? { 'X-Forwarded-For': fwd } : {}),
|
||||
},
|
||||
body: JSON.stringify({ waitlist, email }),
|
||||
cache: 'no-store',
|
||||
})
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: `Could not reach the waitlist: ${e instanceof Error ? e.message : String(e)}` },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
|
||||
const text = await res.text()
|
||||
return new NextResponse(text, {
|
||||
status: res.status,
|
||||
headers: { 'Content-Type': res.headers.get('content-type') ?? 'application/json' },
|
||||
})
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
_We are Hiring_
|
||||
Join us in scaling Langfuse in Berlin, Germany. We are an open source company, we hire in person, we are only hiring technical talent.
|
||||
|
||||
_Open Roles_
|
||||
|
||||
- Product Engineer, 70-130k EUR, 0.25-0.75% Equity, https://www.ycombinator.com/companies/langfuse/jobs/aAvmoFB-product-engineer
|
||||
- Developer Advocate, 60-110k EUR, 0.25-0.5% Equity, https://www.ycombinator.com/companies/langfuse/jobs/uHysbKH-developer-advocate-devrel
|
||||
|
||||
_More Info_
|
||||
|
||||
- https://langfuse.com/careers
|
||||
- https://langfuse.com/docs
|
||||
- https://langfuse.com/changelog
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 70 KiB |
@@ -1,88 +0,0 @@
|
||||
version: "3.5"
|
||||
|
||||
services:
|
||||
server:
|
||||
build:
|
||||
dockerfile: ./web/Dockerfile
|
||||
context: .
|
||||
args:
|
||||
- NEXT_PUBLIC_LANGFUSE_CLOUD_REGION=${NEXT_PUBLIC_LANGFUSE_CLOUD_REGION}
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://postgres:postgres@db:5432/postgres
|
||||
- NEXTAUTH_SECRET=mysecret
|
||||
- SALT=mysalt
|
||||
- ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 # generate via `openssl rand -hex 32`
|
||||
- NEXTAUTH_URL=http://localhost:3000
|
||||
- TELEMETRY_ENABLED=${TELEMETRY_ENABLED:-true}
|
||||
- LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES=${LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES:-false}
|
||||
- LANGFUSE_INIT_ORG_ID=${LANGFUSE_INIT_ORG_ID:-}
|
||||
- LANGFUSE_INIT_ORG_NAME=${LANGFUSE_INIT_ORG_NAME:-}
|
||||
- LANGFUSE_INIT_PROJECT_ID=${LANGFUSE_INIT_PROJECT_ID:-}
|
||||
- LANGFUSE_INIT_PROJECT_NAME=${LANGFUSE_INIT_PROJECT_NAME:-}
|
||||
- LANGFUSE_INIT_PROJECT_PUBLIC_KEY=${LANGFUSE_INIT_PROJECT_PUBLIC_KEY:-}
|
||||
- LANGFUSE_INIT_PROJECT_SECRET_KEY=${LANGFUSE_INIT_PROJECT_SECRET_KEY:-}
|
||||
- LANGFUSE_INIT_USER_EMAIL=${LANGFUSE_INIT_USER_EMAIL:-}
|
||||
- LANGFUSE_INIT_USER_NAME=${LANGFUSE_INIT_USER_NAME:-}
|
||||
- LANGFUSE_INIT_USER_PASSWORD=${LANGFUSE_INIT_USER_PASSWORD:-}
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/api/public/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
worker:
|
||||
build:
|
||||
dockerfile: ./worker/Dockerfile
|
||||
context: .
|
||||
args:
|
||||
- NEXT_PUBLIC_LANGFUSE_CLOUD_REGION=${NEXT_PUBLIC_LANGFUSE_CLOUD_REGION}
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
ports:
|
||||
- "3030:3030"
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://postgres:postgres@db:5432/postgres
|
||||
- NEXTAUTH_SECRET=mysecret
|
||||
- TELEMETRY_ENABLED=${TELEMETRY_ENABLED:-true}
|
||||
- LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES=${LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES:-false}
|
||||
- PORT=${PORT:-3030}
|
||||
- REDIS_HOST=${REDIS_HOST:-redis}
|
||||
- REDIS_PORT=${REDIS_PORT:-6379}
|
||||
- REDIS_AUTH=${REDIS_AUTH:-myredissecret}
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3030/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
redis:
|
||||
image: redis:7.2.4
|
||||
command: >
|
||||
--requirepass ${REDIS_AUTH:-myredissecret}
|
||||
restart: always
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
db:
|
||||
image: postgres
|
||||
restart: always
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DB=postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
volumes:
|
||||
- database_data:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
database_data:
|
||||
driver: local
|
||||
@@ -1,63 +0,0 @@
|
||||
services:
|
||||
clickhouse:
|
||||
image: clickhouse/clickhouse-server
|
||||
user: "101:101"
|
||||
container_name: clickhouse
|
||||
hostname: clickhouse
|
||||
environment:
|
||||
CLICKHOUSE_DB: default
|
||||
CLICKHOUSE_USER: clickhouse
|
||||
CLICKHOUSE_PASSWORD: clickhouse
|
||||
volumes:
|
||||
- langfuse_clickhouse_data:/var/lib/clickhouse
|
||||
- langfuse_clickhouse_logs:/var/log/clickhouse-server
|
||||
ports:
|
||||
- "8123:8123"
|
||||
- "9000:9000"
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
azurite:
|
||||
image: mcr.microsoft.com/azure-storage/azurite
|
||||
container_name: azurite
|
||||
command: azurite-blob --blobHost 0.0.0.0
|
||||
ports:
|
||||
- "10000:10000"
|
||||
volumes:
|
||||
- langfuse_azurite_data:/data
|
||||
|
||||
redis:
|
||||
image: redis:7.2.4
|
||||
restart: always
|
||||
command: >
|
||||
--requirepass ${REDIS_AUTH:-myredissecret}
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
postgres:
|
||||
image: postgres:${POSTGRES_VERSION:-latest}
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
command: ["postgres", "-c", "log_statement=all"]
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DB=postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
volumes:
|
||||
- langfuse_postgres_data:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
langfuse_postgres_data:
|
||||
driver: local
|
||||
langfuse_clickhouse_data:
|
||||
driver: local
|
||||
langfuse_clickhouse_logs:
|
||||
driver: local
|
||||
langfuse_azurite_data:
|
||||
driver: local
|
||||
@@ -1,75 +0,0 @@
|
||||
services:
|
||||
clickhouse:
|
||||
image: clickhouse/clickhouse-server
|
||||
user: "101:101"
|
||||
container_name: clickhouse
|
||||
hostname: clickhouse
|
||||
environment:
|
||||
CLICKHOUSE_DB: default
|
||||
CLICKHOUSE_USER: clickhouse
|
||||
CLICKHOUSE_PASSWORD: clickhouse
|
||||
volumes:
|
||||
- langfuse_clickhouse_data:/var/lib/clickhouse
|
||||
- langfuse_clickhouse_logs:/var/log/clickhouse-server
|
||||
ports:
|
||||
- "8123:8123"
|
||||
- "9000:9000"
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
minio:
|
||||
image: minio/minio
|
||||
container_name: minio
|
||||
entrypoint: sh
|
||||
# create the 'langfuse' bucket before starting the service
|
||||
command: -c 'mkdir -p /data/langfuse && minio server --address ":9000" --console-address ":9001" /data'
|
||||
environment:
|
||||
MINIO_ACCESS_KEY: minio
|
||||
MINIO_SECRET_KEY: miniosecret
|
||||
ports:
|
||||
- "9090:9000"
|
||||
- "9091:9001"
|
||||
volumes:
|
||||
- langfuse_minio_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "mc", "ready", "local"]
|
||||
interval: 1s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 1s
|
||||
|
||||
redis:
|
||||
image: redis:7.2.4
|
||||
restart: always
|
||||
command: >
|
||||
--requirepass ${REDIS_AUTH:-myredissecret}
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
postgres:
|
||||
image: postgres:${POSTGRES_VERSION:-latest}
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
command: ["postgres", "-c", "log_statement=all"]
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DB=postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
volumes:
|
||||
- langfuse_postgres_data:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
langfuse_postgres_data:
|
||||
driver: local
|
||||
langfuse_clickhouse_data:
|
||||
driver: local
|
||||
langfuse_clickhouse_logs:
|
||||
driver: local
|
||||
langfuse_minio_data:
|
||||
driver: local
|
||||
@@ -1,145 +0,0 @@
|
||||
services:
|
||||
langfuse-worker:
|
||||
image: langfuse/langfuse-worker:latest
|
||||
depends_on: &langfuse-depends-on
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "3030:3030"
|
||||
environment: &langfuse-worker-env
|
||||
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/postgres
|
||||
SALT: "mysalt"
|
||||
ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000" # generate via `openssl rand -hex 32`
|
||||
TELEMETRY_ENABLED: ${TELEMETRY_ENABLED:-true}
|
||||
LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES: ${LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES:-true}
|
||||
LANGFUSE_ASYNC_INGESTION_PROCESSING: ${LANGFUSE_ASYNC_INGESTION_PROCESSING:-true}
|
||||
LANGFUSE_ASYNC_CLICKHOUSE_INGESTION_PROCESSING: ${LANGFUSE_ASYNC_CLICKHOUSE_INGESTION_PROCESSING:-true}
|
||||
LANGFUSE_READ_DASHBOARDS_FROM_CLICKHOUSE: ${LANGFUSE_READ_DASHBOARDS_FROM_CLICKHOUSE:-true}
|
||||
LANGFUSE_READ_FROM_POSTGRES_ONLY: ${LANGFUSE_READ_FROM_POSTGRES_ONLY:-false}
|
||||
LANGFUSE_RETURN_FROM_CLICKHOUSE: ${LANGFUSE_RETURN_FROM_CLICKHOUSE:-true}
|
||||
CLICKHOUSE_MIGRATION_URL: ${CLICKHOUSE_MIGRATION_URL:-clickhouse://clickhouse:9000}
|
||||
CLICKHOUSE_URL: ${CLICKHOUSE_URL:-http://clickhouse:8123}
|
||||
CLICKHOUSE_USER: ${CLICKHOUSE_USER:-clickhouse}
|
||||
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-clickhouse}
|
||||
CLICKHOUSE_CLUSTER_ENABLED: ${CLICKHOUSE_CLUSTER_ENABLED:-false}
|
||||
LANGFUSE_S3_EVENT_UPLOAD_ENABLED: ${LANGFUSE_S3_EVENT_UPLOAD_ENABLED:-true}
|
||||
LANGFUSE_S3_EVENT_UPLOAD_BUCKET: ${LANGFUSE_S3_EVENT_UPLOAD_BUCKET:-langfuse}
|
||||
LANGFUSE_S3_EVENT_UPLOAD_REGION: ${LANGFUSE_S3_EVENT_UPLOAD_REGION:-us-east-1}
|
||||
LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID: ${LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID:-minio}
|
||||
LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY: ${LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY:-miniosecret}
|
||||
LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT: ${LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT:-http://minio:9000}
|
||||
LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE: ${LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE:-true}
|
||||
LANGFUSE_S3_EVENT_UPLOAD_PREFIX: ${LANGFUSE_S3_EVENT_UPLOAD_PREFIX:-events/}
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_ENABLED: ${LANGFUSE_S3_MEDIA_UPLOAD_ENABLED:-true}
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_BUCKET: ${LANGFUSE_S3_MEDIA_UPLOAD_BUCKET:-langfuse}
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_REGION: ${LANGFUSE_S3_MEDIA_UPLOAD_REGION:-us-east-1}
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID: ${LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID:-minio}
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY: ${LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY:-miniosecret}
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: ${LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT:-http://minio:9000}
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: ${LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE:-true}
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_PREFIX: ${LANGFUSE_S3_MEDIA_UPLOAD_PREFIX:-media/}
|
||||
REDIS_HOST: ${REDIS_HOST:-redis}
|
||||
REDIS_PORT: ${REDIS_PORT:-6379}
|
||||
REDIS_AUTH: ${REDIS_AUTH:-myredissecret}
|
||||
|
||||
langfuse-web:
|
||||
image: langfuse/langfuse:latest
|
||||
depends_on: *langfuse-depends-on
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
<<: *langfuse-worker-env
|
||||
NEXTAUTH_URL: http://localhost:3000
|
||||
NEXTAUTH_SECRET: mysecret
|
||||
LANGFUSE_INIT_ORG_ID: ${LANGFUSE_INIT_ORG_ID:-}
|
||||
LANGFUSE_INIT_ORG_NAME: ${LANGFUSE_INIT_ORG_NAME:-}
|
||||
LANGFUSE_INIT_PROJECT_ID: ${LANGFUSE_INIT_PROJECT_ID:-}
|
||||
LANGFUSE_INIT_PROJECT_NAME: ${LANGFUSE_INIT_PROJECT_NAME:-}
|
||||
LANGFUSE_INIT_PROJECT_PUBLIC_KEY: ${LANGFUSE_INIT_PROJECT_PUBLIC_KEY:-}
|
||||
LANGFUSE_INIT_PROJECT_SECRET_KEY: ${LANGFUSE_INIT_PROJECT_SECRET_KEY:-}
|
||||
LANGFUSE_INIT_USER_EMAIL: ${LANGFUSE_INIT_USER_EMAIL:-}
|
||||
LANGFUSE_INIT_USER_NAME: ${LANGFUSE_INIT_USER_NAME:-}
|
||||
LANGFUSE_INIT_USER_PASSWORD: ${LANGFUSE_INIT_USER_PASSWORD:-}
|
||||
|
||||
clickhouse:
|
||||
image: clickhouse/clickhouse-server
|
||||
user: "101:101"
|
||||
container_name: clickhouse
|
||||
hostname: clickhouse
|
||||
environment:
|
||||
CLICKHOUSE_DB: default
|
||||
CLICKHOUSE_USER: clickhouse
|
||||
CLICKHOUSE_PASSWORD: clickhouse
|
||||
volumes:
|
||||
- langfuse_clickhouse_data:/var/lib/clickhouse
|
||||
- langfuse_clickhouse_logs:/var/log/clickhouse-server
|
||||
ports:
|
||||
- "8123:8123"
|
||||
- "9000:9000"
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
minio:
|
||||
image: minio/minio
|
||||
container_name: minio
|
||||
entrypoint: sh
|
||||
# create the 'langfuse' bucket before starting the service
|
||||
command: -c 'mkdir -p /data/langfuse && minio server --address ":9000" --console-address ":9001" /data'
|
||||
environment:
|
||||
MINIO_ROOT_USER: minio
|
||||
MINIO_ROOT_PASSWORD: miniosecret
|
||||
ports:
|
||||
- "9090:9000"
|
||||
- "9091:9001"
|
||||
volumes:
|
||||
- langfuse_minio_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "mc", "ready", "local"]
|
||||
interval: 1s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 1s
|
||||
|
||||
redis:
|
||||
image: redis:7
|
||||
restart: always
|
||||
command: >
|
||||
--requirepass ${REDIS_AUTH:-myredissecret}
|
||||
ports:
|
||||
- 6379:6379
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 3s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
|
||||
postgres:
|
||||
image: postgres:${POSTGRES_VERSION:-latest}
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
volumes:
|
||||
- langfuse_postgres_data:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
langfuse_postgres_data:
|
||||
driver: local
|
||||
langfuse_clickhouse_data:
|
||||
driver: local
|
||||
langfuse_clickhouse_logs:
|
||||
driver: local
|
||||
langfuse_minio_data:
|
||||
driver: local
|
||||
@@ -1,46 +0,0 @@
|
||||
services:
|
||||
langfuse-server:
|
||||
image: langfuse/langfuse:2
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://postgres:postgres@db:5432/postgres
|
||||
- NEXTAUTH_SECRET=mysecret
|
||||
- SALT=mysalt
|
||||
- ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 # generate via `openssl rand -hex 32`
|
||||
- NEXTAUTH_URL=http://localhost:3000
|
||||
- TELEMETRY_ENABLED=${TELEMETRY_ENABLED:-true}
|
||||
- LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES=${LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES:-false}
|
||||
- LANGFUSE_INIT_ORG_ID=${LANGFUSE_INIT_ORG_ID:-}
|
||||
- LANGFUSE_INIT_ORG_NAME=${LANGFUSE_INIT_ORG_NAME:-}
|
||||
- LANGFUSE_INIT_PROJECT_ID=${LANGFUSE_INIT_PROJECT_ID:-}
|
||||
- LANGFUSE_INIT_PROJECT_NAME=${LANGFUSE_INIT_PROJECT_NAME:-}
|
||||
- LANGFUSE_INIT_PROJECT_PUBLIC_KEY=${LANGFUSE_INIT_PROJECT_PUBLIC_KEY:-}
|
||||
- LANGFUSE_INIT_PROJECT_SECRET_KEY=${LANGFUSE_INIT_PROJECT_SECRET_KEY:-}
|
||||
- LANGFUSE_INIT_USER_EMAIL=${LANGFUSE_INIT_USER_EMAIL:-}
|
||||
- LANGFUSE_INIT_USER_NAME=${LANGFUSE_INIT_USER_NAME:-}
|
||||
- LANGFUSE_INIT_USER_PASSWORD=${LANGFUSE_INIT_USER_PASSWORD:-}
|
||||
|
||||
db:
|
||||
image: postgres
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DB=postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
volumes:
|
||||
- database_data:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
database_data:
|
||||
driver: local
|
||||
@@ -0,0 +1,74 @@
|
||||
# Unified `/v1` backend endpoints
|
||||
|
||||
The console talks to the unified Hanzo Cloud backend (`hanzoai/cloud`, the
|
||||
casibase API). Base URL: `${NEXT_PUBLIC_CLOUD_URL}/v1`. All requests send cookie
|
||||
credentials; responses are the envelope `{ status, msg, data, data2 }` (`data2`
|
||||
is the total row count on list endpoints).
|
||||
|
||||
Client modules live in `src/lib/api/`.
|
||||
|
||||
## Account / session — `AccountApi`
|
||||
|
||||
| Method | Endpoint |
|
||||
| --- | --- |
|
||||
| `current()` | `GET /get-account` |
|
||||
| `signin(code, state)` | `POST /signin?code&state` |
|
||||
| `signout()` | `POST /signout` |
|
||||
|
||||
## Providers — `ProviderApi`
|
||||
|
||||
| Method | Endpoint |
|
||||
| --- | --- |
|
||||
| `listGlobal()` | `GET /get-global-providers` |
|
||||
| `list({ owner, store, p, pageSize, … })` | `GET /get-providers` |
|
||||
| `get(owner, name)` | `GET /get-provider?id=owner/name` |
|
||||
| `add(p)` | `POST /add-provider` |
|
||||
| `update(owner, name, p)` | `POST /update-provider?id=owner/name` |
|
||||
| `remove(p)` | `POST /delete-provider` |
|
||||
| `refreshMcpTools(p)` | `POST /refresh-mcp-tools` |
|
||||
|
||||
## Model routes — `ModelRouteApi`
|
||||
|
||||
| Method | Endpoint |
|
||||
| --- | --- |
|
||||
| `list({ owner, … })` | `GET /get-model-routes` |
|
||||
| `get(owner, modelName)` | `GET /get-model-route?owner&modelName` |
|
||||
| `add(r)` | `POST /add-model-route` |
|
||||
| `update(owner, modelName, r)` | `POST /update-model-route?owner&modelName` |
|
||||
| `remove(r)` | `POST /delete-model-route` |
|
||||
|
||||
## Applications — `ApplicationApi`
|
||||
|
||||
| Method | Endpoint |
|
||||
| --- | --- |
|
||||
| `list({ owner, … })` | `GET /get-applications` |
|
||||
| `get(owner, name)` | `GET /get-application?id=owner/name` |
|
||||
| `add(a)` | `POST /add-application` |
|
||||
| `update(owner, name, a)` | `POST /update-application?id=owner/name` |
|
||||
| `remove(a)` | `POST /delete-application` |
|
||||
| `deploy(a)` | `POST /deploy-application?id=owner/name` |
|
||||
| `undeploy(owner, name)` | `POST /undeploy-application?id=owner/name` |
|
||||
|
||||
## Stores — `StoreApi`
|
||||
|
||||
| Method | Endpoint |
|
||||
| --- | --- |
|
||||
| `listGlobal()` | `GET /get-global-stores` |
|
||||
| `list(owner)` | `GET /get-stores?owner` |
|
||||
| `get(owner, name)` | `GET /get-store?id=owner/name` |
|
||||
| `names(owner)` | `GET /get-store-names?owner` |
|
||||
| `add(s)` | `POST /add-store` |
|
||||
| `update(owner, name, s)` | `POST /update-store?id=owner/name` |
|
||||
| `remove(s)` | `POST /delete-store` |
|
||||
| `refreshVectors(s)` | `POST /refresh-store-vectors` |
|
||||
|
||||
## Chat — `ChatApi`
|
||||
|
||||
| Method | Endpoint |
|
||||
| --- | --- |
|
||||
| `listGlobal({ … })` | `GET /get-global-chats` |
|
||||
| `list({ user, store, selectedUser, … })` | `GET /get-chats` |
|
||||
| `get(owner, name)` | `GET /get-chat?id=owner/name` |
|
||||
| `add(c)` | `POST /add-chat` |
|
||||
| `update(owner, name, c)` | `POST /update-chat?id=owner/name` |
|
||||
| `remove(c)` | `POST /delete-chat` |
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* e2e: Hanzo Cloud Console — login → API key → AI inference
|
||||
*
|
||||
* z@hanzo.ai is in the `hanzo` org (isGlobalAdmin), so the OrgGate now shows
|
||||
* a dismissible admin banner and renders the full console on console.hanzo.ai.
|
||||
* Admin ops still live at admin.hanzo.ai.
|
||||
*
|
||||
* Credentials (env, never in repo):
|
||||
* HANZO_EMAIL default z@hanzo.ai
|
||||
* HANZO_PASSWORD required
|
||||
* HANZO_API_KEY optional; skip UI flow, go straight to inference
|
||||
* HANZO_API_BASE default https://api.hanzo.ai
|
||||
* BASE_URL default https://console.hanzo.ai
|
||||
*
|
||||
* Run:
|
||||
* HANZO_PASSWORD=xxx pnpm e2e
|
||||
* HANZO_PASSWORD=xxx HANZO_API_KEY=hk-xxx pnpm e2e
|
||||
*/
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
const EMAIL = process.env.HANZO_EMAIL ?? 'z@hanzo.ai'
|
||||
const PASSWORD = process.env.HANZO_PASSWORD ?? ''
|
||||
const API_KEY = process.env.HANZO_API_KEY ?? ''
|
||||
const API_BASE = process.env.HANZO_API_BASE ?? 'https://api.hanzo.ai'
|
||||
const BASE_URL = process.env.BASE_URL ?? 'https://console.hanzo.ai'
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function signIn(page: Page) {
|
||||
await page.goto(`${BASE_URL}/signin`)
|
||||
await page.waitForSelector('input[placeholder="Email"]', { timeout: 20_000 })
|
||||
await page.fill('input[placeholder="Email"]', EMAIL)
|
||||
await page.fill('input[placeholder="Password"]', PASSWORD)
|
||||
await page.click('button:has-text("Sign in")')
|
||||
}
|
||||
|
||||
/** Wait until we're on the dashboard (route replaced to '/'). */
|
||||
async function waitForDashboard(page: Page) {
|
||||
const base = new URL(BASE_URL).origin
|
||||
await page.waitForURL(url => url.origin === base && url.pathname === '/', { timeout: 30_000 })
|
||||
// The dashboard renders product grid cards — wait for at least one visible card/link
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
}
|
||||
|
||||
// ─── tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Public smoke — needs no credentials, so it always runs (in CI, for Dave, etc.)
|
||||
// and catches a dead/blank sign-in gate. The authenticated flows below gate on
|
||||
// HANZO_PASSWORD.
|
||||
test.describe('Hanzo Cloud Console — public', () => {
|
||||
test('sign-in page renders (email/password + OAuth + passkey)', async ({ page }) => {
|
||||
await page.goto(`${BASE_URL}/signin`)
|
||||
await expect(page.locator('input[placeholder="Email"]')).toBeVisible({ timeout: 20_000 })
|
||||
await expect(page.locator('input[placeholder="Password"]')).toBeVisible()
|
||||
await expect(page.locator('button:has-text("Sign in")')).toBeVisible()
|
||||
await expect(page.locator('button:has-text("Continue with GitHub")')).toBeVisible()
|
||||
await expect(page.locator('button:has-text("Continue with Google")')).toBeVisible()
|
||||
await expect(page.locator('text=/passkey/i')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Hanzo Cloud Console e2e', () => {
|
||||
test.skip(!PASSWORD, 'HANZO_PASSWORD not set — skipping live authenticated tests')
|
||||
|
||||
test('login as z@hanzo.ai — dashboard renders', async ({ page }) => {
|
||||
await signIn(page)
|
||||
await waitForDashboard(page)
|
||||
// Dashboard renders — sign-in form must be gone
|
||||
await expect(page.locator('input[placeholder="Password"]')).not.toBeVisible({ timeout: 10_000 })
|
||||
// At least one product category or card is visible (Overview / AI / Compute etc.)
|
||||
await expect(
|
||||
page.locator('a, button, [role="link"]').filter({ hasText: /models|providers|overview|AI/i }).first()
|
||||
).toBeVisible({ timeout: 15_000 })
|
||||
console.log('✓ Signed in; dashboard is rendering')
|
||||
})
|
||||
|
||||
test('admin banner visible (z is isAdmin on console.hanzo.ai)', async ({ page }) => {
|
||||
await signIn(page)
|
||||
await waitForDashboard(page)
|
||||
// OrgGate shows the admin banner for admins on the non-admin console host.
|
||||
// The banner may have been dismissed in a prior run (localStorage). Skip softly.
|
||||
const banner = page.locator('text=/Admin ops|admin\\.hanzo\\.ai/i').first()
|
||||
const visible = await banner.isVisible({ timeout: 5_000 }).catch(() => false)
|
||||
if (visible) {
|
||||
console.log('✓ Admin banner visible')
|
||||
await expect(page.locator('button:has-text("Open admin")')).toBeVisible()
|
||||
} else {
|
||||
console.log('ℹ Admin banner was dismissed (localStorage) — OK')
|
||||
}
|
||||
})
|
||||
|
||||
test('create or confirm API key', async ({ page }) => {
|
||||
await signIn(page)
|
||||
await waitForDashboard(page)
|
||||
|
||||
// API Keys module is at /api-keys (catch-all route, id='api-keys')
|
||||
await page.goto(`${BASE_URL}/api-keys`, { waitUntil: 'domcontentloaded' })
|
||||
|
||||
// Wait for the module to hydrate — look for the page header or key cards
|
||||
await expect(
|
||||
page.locator('text=/API Keys/i, text=/Cloud API key/i, text=/Create.*API key/i').first()
|
||||
).toBeVisible({ timeout: 25_000 })
|
||||
|
||||
const hasKey = page.locator('text=/Cloud API key/i')
|
||||
const noKey = page.locator('text=/Create your Cloud API key/i')
|
||||
const createBtn = page.locator('button:has-text("Create API key")')
|
||||
|
||||
const needsCreate = await noKey.isVisible({ timeout: 3_000 }).catch(() => false)
|
||||
|| await createBtn.isVisible({ timeout: 1_000 }).catch(() => false)
|
||||
|
||||
if (needsCreate) {
|
||||
await createBtn.click()
|
||||
// One-time reveal card with the hk- key
|
||||
await expect(page.locator('text=/hk-/')).toBeVisible({ timeout: 25_000 })
|
||||
await expect(page.locator('text=/shown only once/i')).toBeVisible()
|
||||
await expect(page.locator('button:has-text("Copy")')).toBeVisible()
|
||||
console.log('✓ API key created (hk- one-time reveal shown)')
|
||||
} else {
|
||||
// Key already exists
|
||||
await expect(hasKey).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.locator('text=/hk-…|hk-[A-Za-z0-9]{3,}/i')).toBeVisible({ timeout: 5_000 })
|
||||
console.log('✓ API key already exists (prefix shown)')
|
||||
}
|
||||
})
|
||||
|
||||
test('API key works — GET /v1/models', async ({ page }) => {
|
||||
let apiKey = API_KEY
|
||||
|
||||
if (!apiKey) {
|
||||
await signIn(page)
|
||||
await waitForDashboard(page)
|
||||
await page.goto(`${BASE_URL}/api-keys`, { waitUntil: 'domcontentloaded' })
|
||||
await expect(
|
||||
page.locator('text=/API Keys/i').first()
|
||||
).toBeVisible({ timeout: 25_000 })
|
||||
|
||||
// Rotate (or create) to show the full key on-screen
|
||||
const rotateBtn = page.locator('button:has-text("Rotate")')
|
||||
const createBtn = page.locator('button:has-text("Create API key")')
|
||||
if (await rotateBtn.isVisible({ timeout: 3_000 }).catch(() => false)) {
|
||||
await rotateBtn.click()
|
||||
} else if (await createBtn.isVisible({ timeout: 1_000 }).catch(() => false)) {
|
||||
await createBtn.click()
|
||||
}
|
||||
await expect(page.locator('text=/hk-/')).toBeVisible({ timeout: 25_000 })
|
||||
|
||||
// Grab the key from the monospace reveal element
|
||||
const keyEl = page.locator('[style*="monospace"]').filter({ hasText: /^hk-/ }).first()
|
||||
apiKey = ((await keyEl.textContent()) ?? '').trim()
|
||||
if (!apiKey.startsWith('hk-')) {
|
||||
const m = ((await page.textContent('body')) ?? '').match(/hk-[A-Za-z0-9]{8,}/)
|
||||
apiKey = m ? m[0] : ''
|
||||
}
|
||||
expect(apiKey, 'Could not extract hk- key from page').toMatch(/^hk-/)
|
||||
console.log(`✓ Extracted key prefix: ${apiKey.slice(0, 11)}…`)
|
||||
}
|
||||
|
||||
// Verify the key works against api.hanzo.ai
|
||||
const resp = await page.request.get(`${API_BASE}/v1/models`, {
|
||||
headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json' },
|
||||
timeout: 30_000,
|
||||
})
|
||||
expect(resp.ok(), `GET /v1/models → ${resp.status()}`).toBe(true)
|
||||
const json = await resp.json()
|
||||
expect(json).toMatchObject({ data: expect.any(Array) })
|
||||
const models: Array<{ id: string }> = json.data
|
||||
expect(models.length).toBeGreaterThan(0)
|
||||
const ids = models.map(m => m.id)
|
||||
console.log(`✓ /v1/models: ${models.length} models`)
|
||||
console.log(` GLM present: ${ids.some(id => id.includes('glm'))}`)
|
||||
console.log(` Claude present: ${ids.some(id => id.includes('claude'))}`)
|
||||
console.log(` DeepSeek present: ${ids.some(id => id.includes('deepseek'))}`)
|
||||
console.log(` First 5: ${ids.slice(0, 5).join(', ')}`)
|
||||
})
|
||||
|
||||
test('OpenAI inference — glm-5.2', async ({ page }) => {
|
||||
test.skip(!API_KEY, 'Set HANZO_API_KEY to run inference tests')
|
||||
|
||||
const resp = await page.request.post(`${API_BASE}/v1/chat/completions`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
data: {
|
||||
model: 'glm-5.2',
|
||||
messages: [{ role: 'user', content: 'Reply with only: PONG' }],
|
||||
max_tokens: 16,
|
||||
stream: false,
|
||||
},
|
||||
timeout: 30_000,
|
||||
})
|
||||
expect(resp.ok(), `glm-5.2 → ${resp.status()}`).toBe(true)
|
||||
const json = await resp.json()
|
||||
const text: string = json.choices?.[0]?.message?.content ?? ''
|
||||
expect(text).toBeTruthy()
|
||||
console.log(`✓ glm-5.2: "${text.trim()}"`)
|
||||
})
|
||||
|
||||
test('OpenAI inference — deepseek-v4-pro', async ({ page }) => {
|
||||
test.skip(!API_KEY, 'Set HANZO_API_KEY to run inference tests')
|
||||
|
||||
const resp = await page.request.post(`${API_BASE}/v1/chat/completions`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
data: {
|
||||
model: 'deepseek-v4-pro',
|
||||
messages: [{ role: 'user', content: 'Reply with only: PONG' }],
|
||||
max_tokens: 16,
|
||||
stream: false,
|
||||
},
|
||||
timeout: 30_000,
|
||||
})
|
||||
expect(resp.ok(), `deepseek-v4-pro → ${resp.status()}`).toBe(true)
|
||||
const json = await resp.json()
|
||||
const text: string = json.choices?.[0]?.message?.content ?? ''
|
||||
expect(text).toBeTruthy()
|
||||
console.log(`✓ deepseek-v4-pro: "${text.trim()}"`)
|
||||
})
|
||||
|
||||
test('Anthropic-compat /v1/messages — claude-sonnet-4-6', async ({ page }) => {
|
||||
test.skip(!API_KEY, 'Set HANZO_API_KEY to run inference tests')
|
||||
|
||||
const resp = await page.request.post(`${API_BASE}/v1/messages`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
data: {
|
||||
model: 'claude-sonnet-4-6',
|
||||
messages: [{ role: 'user', content: 'Reply with only: PONG' }],
|
||||
max_tokens: 16,
|
||||
},
|
||||
timeout: 30_000,
|
||||
})
|
||||
expect(resp.ok(), `/v1/messages → ${resp.status()}`).toBe(true)
|
||||
const json = await resp.json()
|
||||
const text: string = json.content?.[0]?.text ?? ''
|
||||
expect(text).toBeTruthy()
|
||||
console.log(`✓ Anthropic-compat /v1/messages: "${text.trim()}"`)
|
||||
})
|
||||
})
|
||||
@@ -1,8 +0,0 @@
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
module.exports = {
|
||||
extends: ["@repo/eslint-config/library.js"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
project: true,
|
||||
},
|
||||
};
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
Langfuse Enterprise License (the “Enterprise License”)
|
||||
Copyright (c) 2024 Langfuse GmbH ('Langfuse')
|
||||
|
||||
With regard to the Langfuse Software:
|
||||
This software and associated documentation files (the "Software") may only be
|
||||
used in production, if you (and any entity that you represent) have agreed to,
|
||||
and are in compliance with, the Langfuse Terms of Service, available
|
||||
at https://langfuse.com/terms (the “Enterprise Terms”), or other
|
||||
agreement governing the use of the Software, as agreed by you and Langfuse,
|
||||
and otherwise have a valid Langfuse Enterprise license.
|
||||
|
||||
Subject to the foregoing sentence, you are free to
|
||||
modify this Software and publish patches to the Software. You agree that Langfuse
|
||||
and/or its licensors (as applicable) retain all right, title and interest in and
|
||||
to all such modifications and/or patches, and all such modifications and/or
|
||||
patches may only be used, copied, modified, displayed, distributed, or otherwise
|
||||
exploited with a valid Langfuse Enterprise license.
|
||||
|
||||
Notwithstanding the foregoing, you may copy and modify
|
||||
the Software for development and testing purposes, without requiring a
|
||||
subscription. You agree that Langfuse and/or its licensors (as applicable) retain
|
||||
all right, title and interest in and to all such modifications. You are not
|
||||
granted any other rights beyond what is expressly stated herein. Subject to the
|
||||
foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,
|
||||
and/or sell the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
For all third party components incorporated into the Langfuse Software, those
|
||||
components are licensed under the original license provided by the owner of the
|
||||
applicable component.
|
||||
@@ -1,5 +0,0 @@
|
||||
# Enterprise Edition
|
||||
|
||||
This folder includes features that are only available in the Enterprise Edition of Langfuse and on Langfuse Cloud.
|
||||
|
||||
See [LICENSE](../LICENSE) and [docs](https://langfuse.com/docs/open-source) for more details.
|
||||
@@ -1,54 +0,0 @@
|
||||
{
|
||||
"name": "@langfuse/ee",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "./dist/src/index.js",
|
||||
"types": "./dist/src/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/src/index.js",
|
||||
"require": "./dist/src/index.js"
|
||||
},
|
||||
"./sso": {
|
||||
"import": "./dist/src/sso/index.js",
|
||||
"require": "./dist/src/sso/index.js"
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": "20"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx --max-warnings 0",
|
||||
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@langfuse/shared": "workspace:*",
|
||||
"@opentelemetry/api": ">=1.0.0 <1.10.0",
|
||||
"axios": "^1.7.7",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"next": "^14.2.21",
|
||||
"next-auth": "^4.24.11",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@types/node": "^20.11.29",
|
||||
"@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",
|
||||
"prettier": "^3.3.3",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsc-watch": "^6.2.0",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"jsonpath-plus": "10.2.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { env } from "../env";
|
||||
|
||||
export const isEeAvailable: boolean =
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined ||
|
||||
env.LANGFUSE_EE_LICENSE_KEY !== undefined;
|
||||
@@ -1,12 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { env as sharedEnv, removeEmptyEnvVariables } from "@langfuse/shared";
|
||||
|
||||
const EnvSchema = z.object({
|
||||
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION: z.string().optional(),
|
||||
LANGFUSE_EE_LICENSE_KEY: z.string().optional(),
|
||||
});
|
||||
|
||||
export const env = {
|
||||
...sharedEnv,
|
||||
...EnvSchema.parse(removeEmptyEnvVariables(process.env)),
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "NodeNext",
|
||||
"module": "NodeNext",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"types": ["node"],
|
||||
"target": "ES2020",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["."],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
name: langfuse
|
||||
error-discrimination:
|
||||
strategy: status-code
|
||||
auth: bearer
|
||||
imports:
|
||||
commons: commons.yml
|
||||
errors:
|
||||
- commons.Error
|
||||
- commons.UnauthorizedError
|
||||
- commons.AccessDeniedError
|
||||
- commons.MethodNotAllowedError
|
||||
@@ -1,13 +0,0 @@
|
||||
errors:
|
||||
Error:
|
||||
status-code: 400
|
||||
type: string
|
||||
UnauthorizedError:
|
||||
status-code: 401
|
||||
type: string
|
||||
AccessDeniedError:
|
||||
status-code: 403
|
||||
type: string
|
||||
MethodNotAllowedError:
|
||||
status-code: 405
|
||||
type: string
|
||||
@@ -1,134 +0,0 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/fern-api/fern/main/fern.schema.json
|
||||
|
||||
service:
|
||||
auth: true
|
||||
base-path: /api/public
|
||||
endpoints:
|
||||
create:
|
||||
docs: Add a score to the database, upserts on id
|
||||
method: POST
|
||||
path: /scores
|
||||
request: CreateScoreRequest
|
||||
|
||||
types:
|
||||
CreateScoreRequest:
|
||||
properties:
|
||||
id: optional<string>
|
||||
traceId: string
|
||||
name: string
|
||||
value:
|
||||
type: CreateScoreValue
|
||||
docs: The value of the score. Must be passed as string for categorical scores, and numeric for boolean and numeric scores. Boolean score values must equal either 1 or 0 (true or false)
|
||||
observationId: optional<string>
|
||||
comment: optional<string>
|
||||
dataType:
|
||||
type: optional<ScoreDataType>
|
||||
docs: When set, must match the score value's type. If not set, will be inferred from the score value or config
|
||||
configId:
|
||||
type: optional<string>
|
||||
docs: Reference a score config on a score. When set, the score name must equal the config name and scores must comply with the config's range and data type. For categorical scores, the value must map to a config category. Numeric scores might be constrained by the score config's max and min values
|
||||
examples:
|
||||
- value:
|
||||
name: "novelty"
|
||||
value: 0.9
|
||||
traceId: "cdef-1234-5678-90ab"
|
||||
- value:
|
||||
name: "consistency"
|
||||
value: 1.2
|
||||
dataType: "NUMERIC"
|
||||
traceId: "cdef-1234-5678-90ab"
|
||||
- value:
|
||||
name: "accuracy"
|
||||
value: 0.9
|
||||
dataType: "NUMERIC"
|
||||
configId: "9203-4567-89ab-cdef"
|
||||
traceId: "cdef-1234-5678-90ab"
|
||||
- value:
|
||||
name: "toxicity"
|
||||
value: "not toxic"
|
||||
traceId: "cdef-1234-5678-90ab"
|
||||
- value:
|
||||
name: "correctness"
|
||||
value: "partially correct"
|
||||
dataType: "CATEGORICAL"
|
||||
configId: "1234-5678-90ab-cdef"
|
||||
traceId: "cdef-1234-5678-90ab"
|
||||
- value:
|
||||
name: "hallucination"
|
||||
value: 0
|
||||
dataType: "BOOLEAN"
|
||||
traceId: "cdef-1234-5678-90ab"
|
||||
- value:
|
||||
name: "helpfulness"
|
||||
value: 1
|
||||
dataType: "BOOLEAN"
|
||||
configId: "1234-5678-90ab-cdef"
|
||||
traceId: "cdef-1234-5678-90ab"
|
||||
BaseScore:
|
||||
properties:
|
||||
id: string
|
||||
traceId: string
|
||||
name: string
|
||||
source: ScoreSource
|
||||
observationId: optional<string>
|
||||
timestamp: datetime
|
||||
createdAt: datetime
|
||||
updatedAt: datetime
|
||||
authorUserId: optional<string>
|
||||
comment: optional<string>
|
||||
configId:
|
||||
type: optional<string>
|
||||
docs: Reference a score config on a score. When set, config and score name must be equal and value must comply to optionally defined numerical range
|
||||
NumericScore:
|
||||
extends: BaseScore
|
||||
properties:
|
||||
value:
|
||||
type: double
|
||||
docs: The numeric value of the score
|
||||
BooleanScore:
|
||||
extends: BaseScore
|
||||
properties:
|
||||
value:
|
||||
type: double
|
||||
docs: The numeric value of the score. Equals 1 for "True" and 0 for "False"
|
||||
stringValue:
|
||||
type: string
|
||||
docs: The string representation of the score value. Is inferred from the numeric value and equals "True" or "False"
|
||||
CategoricalScore:
|
||||
extends: BaseScore
|
||||
properties:
|
||||
value:
|
||||
type: optional<double>
|
||||
docs: Only defined if a config is linked. Represents the numeric category mapping of the stringValue
|
||||
stringValue:
|
||||
type: string
|
||||
docs: The string representation of the score value. If no config is linked, can be any string. Otherwise, must map to a config category
|
||||
Score:
|
||||
discriminant: "dataType"
|
||||
union:
|
||||
NUMERIC:
|
||||
type: NumericScore
|
||||
docs: "Score with NUMERIC data type"
|
||||
CATEGORICAL:
|
||||
type: CategoricalScore
|
||||
docs: "Score with CATEGORICAL data type"
|
||||
BOOLEAN:
|
||||
type: BooleanScore
|
||||
docs: "Score with BOOLEAN data type"
|
||||
ScoreSource:
|
||||
enum:
|
||||
- ANNOTATION
|
||||
- API
|
||||
- EVAL
|
||||
ScoreDataType:
|
||||
enum:
|
||||
- NUMERIC
|
||||
- CATEGORICAL
|
||||
- BOOLEAN
|
||||
|
||||
CreateScoreValue:
|
||||
discriminated: false
|
||||
union:
|
||||
- double
|
||||
- string
|
||||
docs: The value of the score. Must be passed as string for categorical scores, and numeric for boolean and numeric scores
|
||||
@@ -1,21 +0,0 @@
|
||||
default-group: local
|
||||
groups:
|
||||
local:
|
||||
generators:
|
||||
# - name: fernapi/fern-typescript-browser-sdk
|
||||
# version: 0.7.1
|
||||
# output:
|
||||
# location: npm
|
||||
# url: npm.buildwithfern.com
|
||||
# package-name: "@finto-fern/react-client"
|
||||
# config:
|
||||
# namespaceExport: Langfuse
|
||||
# allowCustomFetcher: true
|
||||
- name: fernapi/fern-openapi
|
||||
version: 0.0.26
|
||||
output:
|
||||
location: local-file-system
|
||||
path: ../../../web/public/generated/api-client
|
||||
config:
|
||||
namespaceExport: Langfuse
|
||||
allowCustomFetcher: true
|
||||
@@ -1,29 +0,0 @@
|
||||
name: langfuse
|
||||
docs: |
|
||||
## Authentication
|
||||
|
||||
Authenticate with the API using [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication), get API keys in the project settings:
|
||||
|
||||
- username: Langfuse Public Key
|
||||
- password: Langfuse Secret Key
|
||||
|
||||
## Exports
|
||||
|
||||
- OpenAPI spec: https://cloud.langfuse.com/generated/api/openapi.yml
|
||||
- Postman collection: https://cloud.langfuse.com/generated/postman/collection.json
|
||||
|
||||
error-discrimination:
|
||||
strategy: status-code
|
||||
auth: basic
|
||||
imports:
|
||||
commons: commons.yml
|
||||
errors:
|
||||
- commons.Error
|
||||
- commons.UnauthorizedError
|
||||
- commons.AccessDeniedError
|
||||
- commons.MethodNotAllowedError
|
||||
- commons.NotFoundError
|
||||
headers:
|
||||
X-Langfuse-Sdk-Name: optional<string>
|
||||
X-Langfuse-Sdk-Version: optional<string>
|
||||
X-Langfuse-Public-Key: optional<string>
|
||||
@@ -1,73 +0,0 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/fern-api/fern/main/fern.schema.json
|
||||
imports:
|
||||
pagination: ./utils/pagination.yml
|
||||
commons: ./commons.yml
|
||||
service:
|
||||
auth: true
|
||||
base-path: /api/public
|
||||
endpoints:
|
||||
create:
|
||||
docs: Create a comment. Comments may be attached to different object types (trace, observation, session, prompt).
|
||||
method: POST
|
||||
path: /comments
|
||||
request: CreateCommentRequest
|
||||
response: CreateCommentResponse
|
||||
get:
|
||||
docs: Get all comments
|
||||
method: GET
|
||||
path: /comments
|
||||
request:
|
||||
name: GetCommentsRequest
|
||||
query-parameters:
|
||||
page:
|
||||
type: optional<integer>
|
||||
docs: Page number, starts at 1.
|
||||
limit:
|
||||
type: optional<integer>
|
||||
docs: Limit of items per page. If you encounter api issues due to too large page sizes, try to reduce the limit
|
||||
objectType:
|
||||
type: optional<string>
|
||||
docs: Filter comments by object type (trace, observation, session, prompt).
|
||||
objectId:
|
||||
type: optional<string>
|
||||
docs: Filter comments by object id. If objectType is not provided, an error will be thrown.
|
||||
authorUserId:
|
||||
type: optional<string>
|
||||
docs: Filter comments by author user id.
|
||||
response: GetCommentsResponse
|
||||
get-by-id:
|
||||
docs: Get a comment by id
|
||||
method: GET
|
||||
path: /comments/{commentId}
|
||||
path-parameters:
|
||||
commentId:
|
||||
type: string
|
||||
docs: The unique langfuse identifier of a comment
|
||||
response: commons.Comment
|
||||
types:
|
||||
CreateCommentRequest:
|
||||
properties:
|
||||
projectId:
|
||||
type: string
|
||||
docs: The id of the project to attach the comment to.
|
||||
objectType:
|
||||
type: string
|
||||
docs: The type of the object to attach the comment to (trace, observation, session, prompt).
|
||||
objectId:
|
||||
type: string
|
||||
docs: The id of the object to attach the comment to. If this does not reference a valid existing object, an error will be thrown.
|
||||
content:
|
||||
type: string
|
||||
docs: The content of the comment. May include markdown. Currently limited to 500 characters.
|
||||
authorUserId:
|
||||
type: optional<string>
|
||||
docs: The id of the user who created the comment.
|
||||
CreateCommentResponse:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
docs: The id of the created object in Langfuse
|
||||
GetCommentsResponse:
|
||||
properties:
|
||||
data: list<commons.Comment>
|
||||
meta: pagination.MetaResponse
|
||||
@@ -1,456 +0,0 @@
|
||||
types:
|
||||
# Objects
|
||||
Trace:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
docs: The unique identifier of a trace
|
||||
timestamp:
|
||||
type: datetime
|
||||
docs: The timestamp when the trace was created
|
||||
name:
|
||||
type: optional<string>
|
||||
docs: The name of the trace
|
||||
input:
|
||||
type: optional<unknown>
|
||||
docs: The input data of the trace. Can be any JSON.
|
||||
output:
|
||||
type: optional<unknown>
|
||||
docs: The output data of the trace. Can be any JSON.
|
||||
sessionId:
|
||||
type: optional<string>
|
||||
docs: The session identifier associated with the trace
|
||||
release:
|
||||
type: optional<string>
|
||||
docs: The release version of the application when the trace was created
|
||||
version:
|
||||
type: optional<string>
|
||||
docs: The version of the trace
|
||||
userId:
|
||||
type: optional<string>
|
||||
docs: The user identifier associated with the trace
|
||||
metadata:
|
||||
type: optional<unknown>
|
||||
docs: The metadata associated with the trace. Can be any JSON.
|
||||
tags:
|
||||
type: optional<list<string>>
|
||||
docs: The tags associated with the trace. Can be an array of strings or null.
|
||||
public:
|
||||
type: optional<boolean>
|
||||
docs: Public traces are accessible via url without login
|
||||
TraceWithDetails: # GET /traces
|
||||
extends: Trace
|
||||
properties:
|
||||
htmlPath:
|
||||
type: string
|
||||
docs: Path of trace in Langfuse UI
|
||||
latency:
|
||||
type: double
|
||||
docs: Latency of trace in seconds
|
||||
totalCost:
|
||||
type: double
|
||||
docs: Cost of trace in USD
|
||||
observations:
|
||||
type: list<string>
|
||||
docs: List of observation ids
|
||||
scores:
|
||||
type: list<string>
|
||||
docs: List of score ids
|
||||
TraceWithFullDetails: # GET traces/[traceID]
|
||||
extends: Trace
|
||||
properties:
|
||||
htmlPath:
|
||||
type: string
|
||||
docs: Path of trace in Langfuse UI
|
||||
latency:
|
||||
type: double
|
||||
docs: Latency of trace in seconds
|
||||
totalCost:
|
||||
type: double
|
||||
docs: Cost of trace in USD
|
||||
observations:
|
||||
type: list<ObservationsView>
|
||||
docs: List of observations
|
||||
scores:
|
||||
type: list<Score>
|
||||
docs: List of scores
|
||||
Session:
|
||||
properties:
|
||||
id: string
|
||||
createdAt: datetime
|
||||
projectId: string
|
||||
SessionWithTraces:
|
||||
extends: Session
|
||||
properties:
|
||||
traces: list<Trace>
|
||||
Observation:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
docs: The unique identifier of the observation
|
||||
traceId:
|
||||
type: optional<string>
|
||||
docs: The trace ID associated with the observation
|
||||
type:
|
||||
type: string
|
||||
docs: The type of the observation
|
||||
name:
|
||||
type: optional<string>
|
||||
docs: The name of the observation
|
||||
startTime:
|
||||
type: datetime
|
||||
docs: The start time of the observation
|
||||
endTime:
|
||||
type: optional<datetime>
|
||||
docs: The end time of the observation.
|
||||
completionStartTime:
|
||||
type: optional<datetime>
|
||||
docs: The completion start time of the observation
|
||||
model:
|
||||
type: optional<string>
|
||||
docs: The model used for the observation
|
||||
modelParameters:
|
||||
type: optional<map<string, MapValue>>
|
||||
docs: The parameters of the model used for the observation
|
||||
input:
|
||||
type: optional<unknown>
|
||||
docs: The input data of the observation
|
||||
version:
|
||||
type: optional<string>
|
||||
docs: The version of the observation
|
||||
metadata:
|
||||
type: optional<unknown>
|
||||
docs: Additional metadata of the observation
|
||||
output:
|
||||
type: optional<unknown>
|
||||
docs: The output data of the observation
|
||||
usage:
|
||||
type: optional<Usage>
|
||||
docs: The usage data of the observation
|
||||
level:
|
||||
type: ObservationLevel
|
||||
docs: The level of the observation
|
||||
statusMessage:
|
||||
type: optional<string>
|
||||
docs: The status message of the observation
|
||||
parentObservationId:
|
||||
type: optional<string>
|
||||
docs: The parent observation ID
|
||||
promptId:
|
||||
type: optional<string>
|
||||
docs: The prompt ID associated with the observation
|
||||
|
||||
ObservationsView:
|
||||
extends: Observation
|
||||
properties:
|
||||
promptName:
|
||||
type: optional<string>
|
||||
docs: The name of the prompt associated with the observation
|
||||
promptVersion:
|
||||
type: optional<integer>
|
||||
docs: The version of the prompt associated with the observation
|
||||
modelId:
|
||||
type: optional<string>
|
||||
docs: The unique identifier of the model
|
||||
inputPrice:
|
||||
type: optional<double>
|
||||
docs: The price of the input in USD
|
||||
outputPrice:
|
||||
type: optional<double>
|
||||
docs: The price of the output in USD.
|
||||
totalPrice:
|
||||
type: optional<double>
|
||||
docs: The total price in USD.
|
||||
calculatedInputCost:
|
||||
type: optional<double>
|
||||
docs: The calculated cost of the input in USD
|
||||
calculatedOutputCost:
|
||||
type: optional<double>
|
||||
docs: The calculated cost of the output in USD
|
||||
calculatedTotalCost:
|
||||
type: optional<double>
|
||||
docs: The calculated total cost in USD
|
||||
latency:
|
||||
type: optional<double>
|
||||
docs: The latency in seconds.
|
||||
timeToFirstToken:
|
||||
type: optional<double>
|
||||
docs: The time to the first token in seconds
|
||||
|
||||
Usage:
|
||||
docs: Standard interface for usage and cost
|
||||
properties:
|
||||
input:
|
||||
docs: Number of input units (e.g. tokens)
|
||||
type: optional<integer>
|
||||
output:
|
||||
docs: Number of output units (e.g. tokens)
|
||||
type: optional<integer>
|
||||
total:
|
||||
docs: Defaults to input+output if not set
|
||||
type: optional<integer>
|
||||
unit: optional<ModelUsageUnit>
|
||||
inputCost:
|
||||
docs: USD input cost
|
||||
type: optional<double>
|
||||
outputCost:
|
||||
docs: USD output cost
|
||||
type: optional<double>
|
||||
totalCost:
|
||||
docs: USD total cost, defaults to input+output
|
||||
type: optional<double>
|
||||
ScoreConfig:
|
||||
docs: Configuration for a score
|
||||
properties:
|
||||
id: string
|
||||
name: string
|
||||
createdAt: datetime
|
||||
updatedAt: datetime
|
||||
projectId: string
|
||||
dataType: ScoreDataType
|
||||
isArchived:
|
||||
type: boolean
|
||||
docs: Whether the score config is archived. Defaults to false
|
||||
minValue:
|
||||
type: optional<double>
|
||||
docs: Sets minimum value for numerical scores. If not set, the minimum value defaults to -∞
|
||||
maxValue:
|
||||
type: optional<double>
|
||||
docs: Sets maximum value for numerical scores. If not set, the maximum value defaults to +∞
|
||||
categories:
|
||||
type: optional<list<ConfigCategory>>
|
||||
docs: Configures custom categories for categorical scores
|
||||
description: optional<string>
|
||||
ConfigCategory:
|
||||
properties:
|
||||
value: double
|
||||
label: string
|
||||
BaseScore:
|
||||
properties:
|
||||
id: string
|
||||
traceId: string
|
||||
name: string
|
||||
source: ScoreSource
|
||||
observationId: optional<string>
|
||||
timestamp: datetime
|
||||
createdAt: datetime
|
||||
updatedAt: datetime
|
||||
authorUserId: optional<string>
|
||||
comment: optional<string>
|
||||
configId:
|
||||
type: optional<string>
|
||||
docs: Reference a score config on a score. When set, config and score name must be equal and value must comply to optionally defined numerical range
|
||||
queueId:
|
||||
type: optional<string>
|
||||
docs: Reference an annotation queue on a score. Populated if the score was initially created in an annotation queue.
|
||||
NumericScore:
|
||||
extends: BaseScore
|
||||
properties:
|
||||
value:
|
||||
type: double
|
||||
docs: The numeric value of the score
|
||||
BooleanScore:
|
||||
extends: BaseScore
|
||||
properties:
|
||||
value:
|
||||
type: double
|
||||
docs: The numeric value of the score. Equals 1 for "True" and 0 for "False"
|
||||
stringValue:
|
||||
type: string
|
||||
docs: The string representation of the score value. Is inferred from the numeric value and equals "True" or "False"
|
||||
CategoricalScore:
|
||||
extends: BaseScore
|
||||
properties:
|
||||
value:
|
||||
type: optional<double>
|
||||
docs: Only defined if a config is linked. Represents the numeric category mapping of the stringValue
|
||||
stringValue:
|
||||
type: string
|
||||
docs: The string representation of the score value. If no config is linked, can be any string. Otherwise, must map to a config category
|
||||
Score:
|
||||
discriminant: "dataType"
|
||||
union:
|
||||
NUMERIC:
|
||||
type: NumericScore
|
||||
docs: "Score with NUMERIC data type"
|
||||
CATEGORICAL:
|
||||
type: CategoricalScore
|
||||
docs: "Score with CATEGORICAL data type"
|
||||
BOOLEAN:
|
||||
type: BooleanScore
|
||||
docs: "Score with BOOLEAN data type"
|
||||
|
||||
CreateScoreValue:
|
||||
discriminated: false
|
||||
union:
|
||||
- double
|
||||
- string
|
||||
docs: The value of the score. Must be passed as string for categorical scores, and numeric for boolean and numeric scores
|
||||
|
||||
Comment:
|
||||
properties:
|
||||
id: string
|
||||
projectId: string
|
||||
createdAt: datetime
|
||||
updatedAt: datetime
|
||||
objectType: CommentObjectType
|
||||
objectId: string
|
||||
content: string
|
||||
authorUserId: optional<string>
|
||||
|
||||
Dataset:
|
||||
properties:
|
||||
id: string
|
||||
name: string
|
||||
description: optional<string>
|
||||
metadata: optional<unknown>
|
||||
projectId: string
|
||||
createdAt: datetime
|
||||
updatedAt: datetime
|
||||
DatasetItem:
|
||||
properties:
|
||||
id: string
|
||||
status: DatasetStatus
|
||||
input: optional<unknown>
|
||||
expectedOutput: optional<unknown>
|
||||
metadata: optional<unknown>
|
||||
sourceTraceId: optional<string>
|
||||
sourceObservationId: optional<string>
|
||||
datasetId: string
|
||||
datasetName: string
|
||||
createdAt: datetime
|
||||
updatedAt: datetime
|
||||
DatasetRunItem:
|
||||
properties:
|
||||
id: string
|
||||
datasetRunId: string
|
||||
datasetRunName: string
|
||||
datasetItemId: string
|
||||
traceId: string
|
||||
observationId: optional<string>
|
||||
createdAt: datetime
|
||||
updatedAt: datetime
|
||||
DatasetRun:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
docs: Unique identifier of the dataset run
|
||||
name:
|
||||
type: string
|
||||
docs: Name of the dataset run
|
||||
description:
|
||||
type: optional<string>
|
||||
docs: Description of the run
|
||||
metadata:
|
||||
type: optional<unknown>
|
||||
docs: Metadata of the dataset run
|
||||
datasetId:
|
||||
type: string
|
||||
docs: Id of the associated dataset
|
||||
datasetName:
|
||||
type: string
|
||||
docs: Name of the associated dataset
|
||||
createdAt:
|
||||
type: datetime
|
||||
docs: The date and time when the dataset run was created
|
||||
updatedAt:
|
||||
type: datetime
|
||||
docs: The date and time when the dataset run was last updated
|
||||
DatasetRunWithItems:
|
||||
extends: DatasetRun
|
||||
properties:
|
||||
datasetRunItems: list<DatasetRunItem>
|
||||
Model:
|
||||
docs: Model definition used for transforming usage into USD cost and/or tokenization.
|
||||
properties:
|
||||
id: string
|
||||
modelName:
|
||||
docs: "Name of the model definition. If multiple with the same name exist, they are applied in the following order: (1) custom over built-in, (2) newest according to startTime where model.startTime<observation.startTime"
|
||||
type: string
|
||||
matchPattern:
|
||||
docs: "Regex pattern which matches this model definition to generation.model. Useful in case of fine-tuned models. If you want to exact match, use `(?i)^modelname$`"
|
||||
type: string
|
||||
startDate:
|
||||
docs: Apply only to generations which are newer than this ISO date.
|
||||
type: optional<date>
|
||||
unit:
|
||||
docs: Unit used by this model.
|
||||
type: ModelUsageUnit
|
||||
inputPrice:
|
||||
docs: Price (USD) per input unit
|
||||
type: optional<double>
|
||||
outputPrice:
|
||||
docs: Price (USD) per output unit
|
||||
type: optional<double>
|
||||
totalPrice:
|
||||
docs: Price (USD) per total unit. Cannot be set if input or output price is set.
|
||||
type: optional<double>
|
||||
tokenizerId:
|
||||
docs: Optional. Tokenizer to be applied to observations which match to this model. See docs for more details.
|
||||
type: optional<string>
|
||||
tokenizerConfig:
|
||||
docs: Optional. Configuration for the selected tokenizer. Needs to be JSON. See docs for more details.
|
||||
type: optional<unknown>
|
||||
isLangfuseManaged:
|
||||
type: boolean
|
||||
|
||||
# Utilities
|
||||
ModelUsageUnit:
|
||||
docs: Unit of usage in Langfuse
|
||||
enum:
|
||||
- CHARACTERS
|
||||
- TOKENS
|
||||
- MILLISECONDS
|
||||
- SECONDS
|
||||
- IMAGES
|
||||
- REQUESTS
|
||||
ObservationLevel:
|
||||
enum:
|
||||
- DEBUG
|
||||
- DEFAULT
|
||||
- WARNING
|
||||
- ERROR
|
||||
MapValue:
|
||||
discriminated: false
|
||||
union:
|
||||
- optional<string>
|
||||
- optional<integer>
|
||||
- optional<boolean>
|
||||
- optional<list<string>>
|
||||
CommentObjectType:
|
||||
enum:
|
||||
- TRACE
|
||||
- OBSERVATION
|
||||
- SESSION
|
||||
- PROMPT
|
||||
DatasetStatus:
|
||||
enum:
|
||||
- ACTIVE
|
||||
- ARCHIVED
|
||||
ScoreSource:
|
||||
enum:
|
||||
- ANNOTATION
|
||||
- API
|
||||
- EVAL
|
||||
ScoreDataType:
|
||||
enum:
|
||||
- NUMERIC
|
||||
- BOOLEAN
|
||||
- CATEGORICAL
|
||||
|
||||
errors:
|
||||
Error:
|
||||
status-code: 400
|
||||
type: unknown
|
||||
UnauthorizedError:
|
||||
status-code: 401
|
||||
type: unknown
|
||||
AccessDeniedError:
|
||||
status-code: 403
|
||||
type: unknown
|
||||
NotFoundError:
|
||||
status-code: 404
|
||||
type: unknown
|
||||
MethodNotAllowedError:
|
||||
status-code: 405
|
||||
type: unknown
|
||||
@@ -1,59 +0,0 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/fern-api/fern/main/fern.schema.json
|
||||
imports:
|
||||
commons: ./commons.yml
|
||||
pagination: ./utils/pagination.yml
|
||||
service:
|
||||
auth: true
|
||||
base-path: /api/public
|
||||
endpoints:
|
||||
create:
|
||||
method: POST
|
||||
docs: Create a dataset item
|
||||
path: /dataset-items
|
||||
request: CreateDatasetItemRequest
|
||||
response: commons.DatasetItem
|
||||
get:
|
||||
docs: Get a dataset item
|
||||
method: GET
|
||||
path: /dataset-items/{id}
|
||||
path-parameters:
|
||||
id:
|
||||
type: string
|
||||
response: commons.DatasetItem
|
||||
list:
|
||||
docs: Get dataset items
|
||||
method: GET
|
||||
path: /dataset-items
|
||||
request:
|
||||
name: GetDatasetItemsRequest
|
||||
query-parameters:
|
||||
datasetName: optional<string>
|
||||
sourceTraceId: optional<string>
|
||||
sourceObservationId: optional<string>
|
||||
page:
|
||||
type: optional<integer>
|
||||
docs: page number, starts at 1
|
||||
limit:
|
||||
type: optional<integer>
|
||||
docs: limit of items per page
|
||||
response: PaginatedDatasetItems
|
||||
|
||||
types:
|
||||
CreateDatasetItemRequest:
|
||||
properties:
|
||||
datasetName: string
|
||||
input: optional<unknown>
|
||||
expectedOutput: optional<unknown>
|
||||
metadata: optional<unknown>
|
||||
sourceTraceId: optional<string>
|
||||
sourceObservationId: optional<string>
|
||||
id:
|
||||
type: optional<string>
|
||||
docs: Dataset items are upserted on their id. Id needs to be unique (project-level) and cannot be reused across datasets.
|
||||
status:
|
||||
type: optional<commons.DatasetStatus>
|
||||
docs: Defaults to ACTIVE for newly created items
|
||||
PaginatedDatasetItems:
|
||||
properties:
|
||||
data: list<commons.DatasetItem>
|
||||
meta: pagination.MetaResponse
|
||||
@@ -1,28 +0,0 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/fern-api/fern/main/fern.schema.json
|
||||
imports:
|
||||
commons: ./commons.yml
|
||||
service:
|
||||
auth: true
|
||||
base-path: /api/public
|
||||
endpoints:
|
||||
create:
|
||||
method: POST
|
||||
docs: Create a dataset run item
|
||||
path: /dataset-run-items
|
||||
request: CreateDatasetRunItemRequest
|
||||
response: commons.DatasetRunItem
|
||||
types:
|
||||
CreateDatasetRunItemRequest:
|
||||
properties:
|
||||
runName: string
|
||||
runDescription:
|
||||
type: optional<string>
|
||||
docs: Description of the run. If run exists, description will be updated.
|
||||
metadata:
|
||||
type: optional<unknown>
|
||||
docs: Metadata of the dataset run, updates run if run already exists
|
||||
datasetItemId: string
|
||||
observationId: optional<string>
|
||||
traceId:
|
||||
type: optional<string>
|
||||
docs: traceId should always be provided. For compatibility with older SDK versions it can also be inferred from the provided observationId.
|
||||
@@ -1,74 +0,0 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/fern-api/fern/main/fern.schema.json
|
||||
imports:
|
||||
commons: ./commons.yml
|
||||
pagination: ./utils/pagination.yml
|
||||
service:
|
||||
auth: true
|
||||
base-path: /api/public
|
||||
endpoints:
|
||||
list:
|
||||
method: GET
|
||||
docs: Get all datasets
|
||||
path: /v2/datasets
|
||||
request:
|
||||
name: GetDatasetsRequest
|
||||
query-parameters:
|
||||
page:
|
||||
type: optional<integer>
|
||||
docs: page number, starts at 1
|
||||
limit:
|
||||
type: optional<integer>
|
||||
docs: limit of items per page
|
||||
response: PaginatedDatasets
|
||||
get:
|
||||
method: GET
|
||||
docs: Get a dataset
|
||||
path: /v2/datasets/{datasetName}
|
||||
path-parameters:
|
||||
datasetName: string
|
||||
response: commons.Dataset
|
||||
create:
|
||||
method: POST
|
||||
docs: Create a dataset
|
||||
path: /v2/datasets
|
||||
request: CreateDatasetRequest
|
||||
response: commons.Dataset
|
||||
getRun:
|
||||
method: GET
|
||||
docs: Get a dataset run and its items
|
||||
path: /datasets/{datasetName}/runs/{runName}
|
||||
path-parameters:
|
||||
datasetName: string
|
||||
runName: string
|
||||
response: commons.DatasetRunWithItems
|
||||
getRuns:
|
||||
method: GET
|
||||
docs: Get dataset runs
|
||||
path: /datasets/{datasetName}/runs
|
||||
path-parameters:
|
||||
datasetName: string
|
||||
request:
|
||||
name: GetDatasetRunsRequest
|
||||
query-parameters:
|
||||
page:
|
||||
type: optional<integer>
|
||||
docs: page number, starts at 1
|
||||
limit:
|
||||
type: optional<integer>
|
||||
docs: limit of items per page
|
||||
response: PaginatedDatasetRuns
|
||||
|
||||
types:
|
||||
PaginatedDatasets:
|
||||
properties:
|
||||
data: list<commons.Dataset>
|
||||
meta: pagination.MetaResponse
|
||||
CreateDatasetRequest:
|
||||
properties:
|
||||
name: string
|
||||
description: optional<string>
|
||||
metadata: optional<unknown>
|
||||
PaginatedDatasetRuns:
|
||||
properties:
|
||||
data: list<commons.DatasetRun>
|
||||
meta: pagination.MetaResponse
|
||||
@@ -1,28 +0,0 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/fern-api/fern/main/fern.schema.json
|
||||
service:
|
||||
auth: false
|
||||
base-path: /api/public
|
||||
endpoints:
|
||||
health:
|
||||
docs: Check health of API and database
|
||||
method: GET
|
||||
path: /health
|
||||
response: HealthResponse
|
||||
errors:
|
||||
- ServiceUnavailableError
|
||||
|
||||
types:
|
||||
HealthResponse:
|
||||
properties:
|
||||
version:
|
||||
type: string
|
||||
docs: Langfuse server version
|
||||
status: string
|
||||
examples:
|
||||
- value:
|
||||
version: 1.25.0
|
||||
status: OK
|
||||
|
||||
errors:
|
||||
ServiceUnavailableError:
|
||||
status-code: 503
|
||||
@@ -1,314 +0,0 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/fern-api/fern/main/fern.schema.json
|
||||
imports:
|
||||
pagination: ./utils/pagination.yml
|
||||
commons: ./commons.yml
|
||||
service:
|
||||
auth: true
|
||||
base-path: /api/public
|
||||
endpoints:
|
||||
batch:
|
||||
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.
|
||||
|
||||
Notes:
|
||||
- Batch sizes are limited to 3.5 MB in total. You need to adjust the number of events per batch accordingly.
|
||||
- The API does not return a 4xx status code for input errors. Instead, it responds with a 207 status code, which includes a list of the encountered errors.
|
||||
method: POST
|
||||
path: /ingestion
|
||||
request:
|
||||
name: IngestionRequest
|
||||
body:
|
||||
properties:
|
||||
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:
|
||||
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:
|
||||
type: CreateObservationEvent
|
||||
docs: Deprecated event type
|
||||
observation-update:
|
||||
type: UpdateObservationEvent
|
||||
docs: Deprecated event type
|
||||
|
||||
ObservationType:
|
||||
enum:
|
||||
- SPAN
|
||||
- GENERATION
|
||||
- EVENT
|
||||
|
||||
IngestionUsage:
|
||||
discriminated: false
|
||||
union:
|
||||
- commons.Usage
|
||||
- OpenAIUsage
|
||||
|
||||
OpenAIUsage:
|
||||
docs: Usage interface of OpenAI for improved compatibility.
|
||||
properties:
|
||||
promptTokens: optional<integer>
|
||||
completionTokens: optional<integer>
|
||||
totalTokens: optional<integer>
|
||||
|
||||
OptionalObservationBody:
|
||||
properties:
|
||||
traceId: optional<string>
|
||||
name: optional<string>
|
||||
startTime: optional<datetime>
|
||||
metadata: optional<unknown>
|
||||
input: optional<unknown>
|
||||
output: optional<unknown>
|
||||
level: optional<commons.ObservationLevel>
|
||||
statusMessage: optional<string>
|
||||
parentObservationId: optional<string>
|
||||
version: optional<string>
|
||||
|
||||
CreateEventBody:
|
||||
extends: OptionalObservationBody
|
||||
properties:
|
||||
id: optional<string>
|
||||
|
||||
UpdateEventBody:
|
||||
extends: OptionalObservationBody
|
||||
properties:
|
||||
id: string
|
||||
|
||||
CreateSpanBody:
|
||||
extends: CreateEventBody
|
||||
properties:
|
||||
endTime: optional<datetime>
|
||||
|
||||
UpdateSpanBody:
|
||||
extends: UpdateEventBody
|
||||
properties:
|
||||
endTime: optional<datetime>
|
||||
|
||||
CreateGenerationBody:
|
||||
extends: CreateSpanBody
|
||||
properties:
|
||||
completionStartTime: optional<datetime>
|
||||
model: optional<string>
|
||||
modelParameters: optional<map<string, commons.MapValue>>
|
||||
usage: optional<IngestionUsage>
|
||||
promptName: optional<string>
|
||||
promptVersion: optional<integer>
|
||||
|
||||
UpdateGenerationBody:
|
||||
extends: UpdateSpanBody
|
||||
properties:
|
||||
completionStartTime: optional<datetime>
|
||||
model: optional<string>
|
||||
modelParameters: optional<map<string, commons.MapValue>>
|
||||
usage: optional<IngestionUsage>
|
||||
promptName: optional<string>
|
||||
promptVersion: optional<integer>
|
||||
|
||||
ObservationBody:
|
||||
properties:
|
||||
id: optional<string>
|
||||
traceId: optional<string>
|
||||
type: ObservationType
|
||||
name: optional<string>
|
||||
startTime: optional<datetime>
|
||||
endTime: optional<datetime>
|
||||
completionStartTime: optional<datetime>
|
||||
model: optional<string>
|
||||
modelParameters: optional<map<string, commons.MapValue>>
|
||||
input: optional<unknown>
|
||||
version: optional<string>
|
||||
metadata: optional<unknown>
|
||||
output: optional<unknown>
|
||||
usage: optional<commons.Usage>
|
||||
level: optional<commons.ObservationLevel>
|
||||
statusMessage: optional<string>
|
||||
parentObservationId: optional<string>
|
||||
|
||||
TraceBody:
|
||||
properties:
|
||||
id: optional<string>
|
||||
timestamp: optional<datetime>
|
||||
name: optional<string>
|
||||
userId: optional<string>
|
||||
input: optional<unknown>
|
||||
output: optional<unknown>
|
||||
sessionId: optional<string>
|
||||
release: optional<string>
|
||||
version: optional<string>
|
||||
metadata: optional<unknown>
|
||||
tags: optional<list<string>>
|
||||
public:
|
||||
type: optional<boolean>
|
||||
docs: Make trace publicly accessible via url
|
||||
|
||||
SDKLogBody:
|
||||
properties:
|
||||
log: unknown
|
||||
|
||||
ScoreBody:
|
||||
properties:
|
||||
id: optional<string>
|
||||
traceId: string
|
||||
name: string
|
||||
value:
|
||||
type: commons.CreateScoreValue
|
||||
docs: The value of the score. Must be passed as string for categorical scores, and numeric for boolean and numeric scores. Boolean score values must equal either 1 or 0 (true or false)
|
||||
observationId: optional<string>
|
||||
comment: optional<string>
|
||||
dataType:
|
||||
type: optional<commons.ScoreDataType>
|
||||
docs: When set, must match the score value's type. If not set, will be inferred from the score value or config
|
||||
configId:
|
||||
type: optional<string>
|
||||
docs: Reference a score config on a score. When set, the score name must equal the config name and scores must comply with the config's range and data type. For categorical scores, the value must map to a config category. Numeric scores might be constrained by the score config's max and min values
|
||||
examples:
|
||||
- value:
|
||||
name: "novelty"
|
||||
value: 0.9
|
||||
traceId: "cdef-1234-5678-90ab"
|
||||
- value:
|
||||
name: "consistency"
|
||||
value: 1.2
|
||||
dataType: "NUMERIC"
|
||||
traceId: "cdef-1234-5678-90ab"
|
||||
- value:
|
||||
name: "accuracy"
|
||||
value: 0.9
|
||||
dataType: "NUMERIC"
|
||||
configId: "9203-4567-89ab-cdef"
|
||||
traceId: "cdef-1234-5678-90ab"
|
||||
- value:
|
||||
name: "toxicity"
|
||||
value: "not toxic"
|
||||
traceId: "cdef-1234-5678-90ab"
|
||||
- value:
|
||||
name: "correctness"
|
||||
value: "partially correct"
|
||||
dataType: "CATEGORICAL"
|
||||
configId: "1234-5678-90ab-cdef"
|
||||
traceId: "cdef-1234-5678-90ab"
|
||||
- value:
|
||||
name: "hallucination"
|
||||
value: 0
|
||||
dataType: "BOOLEAN"
|
||||
traceId: "cdef-1234-5678-90ab"
|
||||
- value:
|
||||
name: "helpfulness"
|
||||
value: 1
|
||||
dataType: "BOOLEAN"
|
||||
configId: "1234-5678-90ab-cdef"
|
||||
traceId: "cdef-1234-5678-90ab"
|
||||
|
||||
BaseEvent:
|
||||
properties:
|
||||
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
|
||||
properties:
|
||||
body: TraceBody
|
||||
|
||||
CreateObservationEvent:
|
||||
extends: BaseEvent
|
||||
properties:
|
||||
body: ObservationBody
|
||||
|
||||
UpdateObservationEvent:
|
||||
extends: BaseEvent
|
||||
properties:
|
||||
body: ObservationBody
|
||||
|
||||
ScoreEvent:
|
||||
extends: BaseEvent
|
||||
properties:
|
||||
body: ScoreBody
|
||||
|
||||
SDKLogEvent:
|
||||
extends: BaseEvent
|
||||
properties:
|
||||
body: SDKLogBody
|
||||
|
||||
CreateGenerationEvent:
|
||||
extends: BaseEvent
|
||||
properties:
|
||||
body: CreateGenerationBody
|
||||
|
||||
UpdateGenerationEvent:
|
||||
extends: BaseEvent
|
||||
properties:
|
||||
body: UpdateGenerationBody
|
||||
|
||||
CreateSpanEvent:
|
||||
extends: BaseEvent
|
||||
properties:
|
||||
body: CreateSpanBody
|
||||
|
||||
UpdateSpanEvent:
|
||||
extends: BaseEvent
|
||||
properties:
|
||||
body: UpdateSpanBody
|
||||
|
||||
CreateEventEvent:
|
||||
extends: BaseEvent
|
||||
properties:
|
||||
body: CreateEventBody
|
||||
|
||||
IngestionSuccess:
|
||||
properties:
|
||||
id: string
|
||||
status: integer
|
||||
|
||||
IngestionError:
|
||||
properties:
|
||||
id: string
|
||||
status: integer
|
||||
message: optional<string>
|
||||
error: optional<unknown>
|
||||
|
||||
IngestionResponse:
|
||||
properties:
|
||||
successes: list<IngestionSuccess>
|
||||
errors: list<IngestionError>
|
||||
@@ -1,103 +0,0 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/fern-api/fern/main/fern.schema.json
|
||||
imports:
|
||||
commons: ./commons.yml
|
||||
|
||||
service:
|
||||
auth: true
|
||||
base-path: /api/public
|
||||
endpoints:
|
||||
get:
|
||||
docs: Get a media record
|
||||
method: GET
|
||||
path: /media/{mediaId}
|
||||
path-parameters:
|
||||
mediaId:
|
||||
type: string
|
||||
docs: The unique langfuse identifier of a media record
|
||||
response: GetMediaResponse
|
||||
|
||||
patch:
|
||||
docs: Patch a media record
|
||||
method: PATCH
|
||||
path: /media/{mediaId}
|
||||
path-parameters:
|
||||
mediaId:
|
||||
type: string
|
||||
docs: The unique langfuse identifier of a media record
|
||||
request: PatchMediaBody
|
||||
|
||||
getUploadUrl:
|
||||
docs: Get a presigned upload URL for a media record
|
||||
method: POST
|
||||
path: /media
|
||||
request: GetMediaUploadUrlRequest
|
||||
response: GetMediaUploadUrlResponse
|
||||
|
||||
types:
|
||||
GetMediaResponse:
|
||||
properties:
|
||||
mediaId:
|
||||
type: string
|
||||
docs: The unique langfuse identifier of a media record
|
||||
contentType:
|
||||
type: string
|
||||
docs: The MIME type of the media record
|
||||
contentLength:
|
||||
type: integer
|
||||
docs: The size of the media record in bytes
|
||||
uploadedAt:
|
||||
type: datetime
|
||||
docs: The date and time when the media record was uploaded
|
||||
url:
|
||||
type: string
|
||||
docs: The download URL of the media record
|
||||
urlExpiry:
|
||||
type: string
|
||||
docs: The expiry date and time of the media record download URL
|
||||
|
||||
PatchMediaBody:
|
||||
properties:
|
||||
uploadedAt:
|
||||
type: datetime
|
||||
docs: The date and time when the media record was uploaded
|
||||
uploadHttpStatus:
|
||||
type: integer
|
||||
docs: The HTTP status code of the upload
|
||||
uploadHttpError:
|
||||
type: optional<string>
|
||||
docs: The HTTP error message of the upload
|
||||
uploadTimeMs:
|
||||
type: optional<integer>
|
||||
docs: The time in milliseconds it took to upload the media record
|
||||
|
||||
GetMediaUploadUrlRequest:
|
||||
properties:
|
||||
traceId:
|
||||
type: string
|
||||
docs: The trace ID associated with the media record
|
||||
observationId:
|
||||
type: optional<string>
|
||||
docs: The observation ID associated with the media record. If the media record is associated directly with a trace, this will be null.
|
||||
contentType: MediaContentType
|
||||
contentLength:
|
||||
type: integer
|
||||
docs: The size of the media record in bytes
|
||||
sha256Hash:
|
||||
type: string
|
||||
docs: The SHA-256 hash of the media record
|
||||
field:
|
||||
type: string
|
||||
docs: The trace / observation field the media record is associated with. This can be one of `input`, `output`, `metadata`
|
||||
|
||||
GetMediaUploadUrlResponse:
|
||||
properties:
|
||||
uploadUrl:
|
||||
type: optional<string>
|
||||
docs: The presigned upload URL. If the asset is already uploaded, this will be null
|
||||
mediaId:
|
||||
type: string
|
||||
docs: The unique langfuse identifier of a media record
|
||||
|
||||
MediaContentType:
|
||||
type: literal<"image/png","image/jpeg","image/jpg","image/webp","audio/mpeg","audio/mp3","audio/wav","text/plain","application/pdf">
|
||||
docs: The MIME type of the media record
|
||||
@@ -1,72 +0,0 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/fern-api/fern/main/fern.schema.json
|
||||
imports:
|
||||
pagination: ./utils/pagination.yml
|
||||
commons: ./commons.yml
|
||||
service:
|
||||
auth: true
|
||||
base-path: /api/public
|
||||
endpoints:
|
||||
daily:
|
||||
docs: Get daily metrics of the Langfuse project
|
||||
method: GET
|
||||
path: /metrics/daily
|
||||
request:
|
||||
name: GetDailyMetricsRequest
|
||||
query-parameters:
|
||||
page:
|
||||
type: optional<integer>
|
||||
docs: page number, starts at 1
|
||||
limit:
|
||||
type: optional<integer>
|
||||
docs: limit of items per page
|
||||
traceName:
|
||||
type: optional<string>
|
||||
docs: Optional filter by the name of the trace
|
||||
userId:
|
||||
type: optional<string>
|
||||
docs: Optional filter by the userId associated with the trace
|
||||
tags:
|
||||
type: optional<string>
|
||||
allow-multiple: true
|
||||
docs: Optional filter for metrics where traces include all of these tags
|
||||
fromTimestamp:
|
||||
type: optional<datetime>
|
||||
docs: Optional filter to only include traces and observations on or after a certain datetime (ISO 8601)
|
||||
toTimestamp:
|
||||
type: optional<datetime>
|
||||
docs: Optional filter to only include traces and observations before a certain datetime (ISO 8601)
|
||||
response: DailyMetrics
|
||||
types:
|
||||
DailyMetrics:
|
||||
properties:
|
||||
data:
|
||||
type: list<DailyMetricsDetails>
|
||||
docs: A list of daily metrics, only days with ingested data are included.
|
||||
meta: pagination.MetaResponse
|
||||
DailyMetricsDetails:
|
||||
properties:
|
||||
date: date
|
||||
countTraces: integer
|
||||
countObservations: integer
|
||||
totalCost:
|
||||
type: double
|
||||
docs: Total model cost in USD
|
||||
usage: list<UsageByModel>
|
||||
UsageByModel:
|
||||
docs: Daily usage of a given model. Usage corresponds to the unit set for the specific model (e.g. tokens).
|
||||
properties:
|
||||
model: optional<string>
|
||||
inputUsage:
|
||||
type: integer
|
||||
docs: Total number of generation input units (e.g. tokens)
|
||||
outputUsage:
|
||||
type: integer
|
||||
docs: Total number of generation output units (e.g. tokens)
|
||||
totalUsage:
|
||||
type: integer
|
||||
docs: Total number of generation total units (e.g. tokens)
|
||||
countTraces: integer
|
||||
countObservations: integer
|
||||
totalCost:
|
||||
type: double
|
||||
docs: Total model cost in USD
|
||||
@@ -1,76 +0,0 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/fern-api/fern/main/fern.schema.json
|
||||
imports:
|
||||
commons: ./commons.yml
|
||||
pagination: ./utils/pagination.yml
|
||||
service:
|
||||
auth: true
|
||||
base-path: /api/public
|
||||
endpoints:
|
||||
create:
|
||||
method: POST
|
||||
docs: Create a model
|
||||
path: /models
|
||||
request: CreateModelRequest
|
||||
response: commons.Model
|
||||
list:
|
||||
method: GET
|
||||
docs: Get all models
|
||||
path: /models
|
||||
request:
|
||||
name: GetModelsRequest
|
||||
query-parameters:
|
||||
page:
|
||||
type: optional<integer>
|
||||
docs: page number, starts at 1
|
||||
limit:
|
||||
type: optional<integer>
|
||||
docs: limit of items per page
|
||||
response: PaginatedModels
|
||||
get:
|
||||
method: GET
|
||||
docs: Get a model
|
||||
path: /models/{id}
|
||||
path-parameters:
|
||||
id: string
|
||||
response: commons.Model
|
||||
delete:
|
||||
method: DELETE
|
||||
docs: Delete a model. Cannot delete models managed by Langfuse. You can create your own definition with the same modelName to override the definition though.
|
||||
path: /models/{id}
|
||||
path-parameters:
|
||||
id: string
|
||||
|
||||
types:
|
||||
PaginatedModels:
|
||||
properties:
|
||||
data: list<commons.Model>
|
||||
meta: pagination.MetaResponse
|
||||
CreateModelRequest:
|
||||
properties:
|
||||
modelName:
|
||||
docs: "Name of the model definition. If multiple with the same name exist, they are applied in the following order: (1) custom over built-in, (2) newest according to startTime where model.startTime<observation.startTime"
|
||||
type: string
|
||||
matchPattern:
|
||||
docs: "Regex pattern which matches this model definition to generation.model. Useful in case of fine-tuned models. If you want to exact match, use `(?i)^modelname$`"
|
||||
type: string
|
||||
startDate:
|
||||
docs: Apply only to generations which are newer than this ISO date.
|
||||
type: optional<datetime>
|
||||
unit:
|
||||
docs: Unit used by this model.
|
||||
type: commons.ModelUsageUnit
|
||||
inputPrice:
|
||||
docs: Price (USD) per input unit
|
||||
type: optional<double>
|
||||
outputPrice:
|
||||
docs: Price (USD) per output unit
|
||||
type: optional<double>
|
||||
totalPrice:
|
||||
docs: Price (USD) per total units. Cannot be set if input or output price is set.
|
||||
type: optional<double>
|
||||
tokenizerId:
|
||||
docs: Optional. Tokenizer to be applied to observations which match to this model. See docs for more details.
|
||||
type: optional<string>
|
||||
tokenizerConfig:
|
||||
docs: Optional. Configuration for the selected tokenizer. Needs to be JSON. See docs for more details.
|
||||
type: optional<unknown>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user