Compare commits

...
20 Commits
Author SHA1 Message Date
Max Deichmann a4eedbaba3 chore: release v2.37.2
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (20) (push) Waiting to run
CI/CD / tests-worker (20) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2024-05-01 13:00:20 +02:00
Max DeichmannandGitHub 32de95648c perf: remove read operation from events table (#1933) 2024-05-01 10:30:46 +00:00
Marc KlingenandGitHub 15c97956bf ui: move new prompt to datatable toolbar (#1929) 2024-04-30 21:32:14 +00:00
Marc KlingenandGitHub debde9c5d3 fix: openapi spec publishing and add postman collection (#1928) 2024-04-30 23:15:33 +02:00
Hassieb PakzadandGitHub 0f78cea5ce fix: add labels to fern definition for prompt return types (#1923) 2024-04-30 19:21:46 +02:00
Marc KlingenandGitHub a3a25425c0 docs: update contributing 2024-04-30 14:27:32 +02:00
Marc KlingenandGitHub 489e812fe9 docs: update CONTRIBUTING.md 2024-04-30 14:09:27 +02:00
Marc Klingen 42589e31ff docs: add descriptions to new v2 prompt api params 2024-04-30 00:43:36 +02:00
Max Deichmann 0cf58be012 chore: release v2.37.1
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (20) (push) Waiting to run
CI/CD / tests-worker (20) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2024-04-29 21:07:37 +02:00
Max DeichmannandGitHub c338db5a4d fix: deduplicate events on worker ingestion (#1908) 2024-04-29 19:01:48 +00:00
Max Deichmann 6b22c19ce1 chore: release v2.37.0
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (20) (push) Waiting to run
CI/CD / tests-worker (20) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2024-04-29 20:35:34 +02:00
Hassieb PakzadandGitHub a9cac5d6d2 chore: adds mig for latest tag on prompts (#1907) 2024-04-29 20:28:12 +02:00
Hassieb PakzadandGitHub fb1c22896b feat: adds public/v2/prompts (#1846) 2024-04-29 19:44:58 +02:00
Max DeichmannandGitHub 8cb9d5fb60 chore: reduce logging noise (#1903) 2024-04-29 13:33:49 +00:00
Max DeichmannandGitHub c027575dc1 docs: update chart in contributing (#1901) 2024-04-29 14:44:34 +02:00
Marc KlingenandGitHub 870610b575 fix: render layout/menu also when user has no project yet, otherwise cannot sign out (#1900)
Fixes #1787
2024-04-29 10:33:24 +00:00
Max Deichmann 5547e5cc88 chore: release v2.36.1
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (20) (push) Waiting to run
CI/CD / tests-worker (20) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2024-04-29 12:22:16 +02:00
Max DeichmannandGitHub dd4a92f9d8 fix: correctly filter prompt on production promotion (#1899) 2024-04-29 10:21:08 +00:00
Max DeichmannandGitHub 9813cd27e0 perf: reduce sentry sampling (#1897) 2024-04-29 08:11:22 +00:00
Marc Klingen b4c636d430 docs: add ENABLE_EVENT_LOG to example env 2024-04-28 12:12:54 +02:00
56 changed files with 2625 additions and 3196 deletions
+3
View File
@@ -76,6 +76,9 @@ LANGFUSE_CSP_ENFORCE_HTTPS="true"
# The page size can be adjusted if needed to optimize performance
# DB_EXPORT_PAGE_SIZE=1000
# Disable logging to `events` table in db
# ENABLE_EVENT_LOG=false
### START Langfuse Cloud Config
# Used for Langfuse Cloud deployments
+59 -41
View File
@@ -53,53 +53,63 @@ A good first step is to search for open [issues](https://github.com/langfuse/lan
### Architecture Overview
> [!NOTE]
> Infrastructure will change in Langfuse version 3.0. More in the [GitHub Discussions](https://github.com/orgs/langfuse/discussions/1902).
> `langfuse/langfuse/worker` is under active development and not recommended for production use in Langfuse 2.x
```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
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]
subgraph s1["Application (langfuse/langfuse)"]
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
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
subgraph s5["Application (langfuse/langfuse/worker)"]
Worker_API[Public HTTP API]
end
API --> Worker_API
Worker_API --- DB
Worker_API --- Redis
Prisma --- DB
JS --- API
Python --- API
```
### Database Overview
### Network 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">
### Infrastructure & 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
@@ -114,6 +124,14 @@ flowchart LR
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:
+7 -1
View File
@@ -1,11 +1,17 @@
name: langfuse
docs: |
## Authentication
Authenticate with the API using Basic Auth, get API keys in the project settings:
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/openapi-server.yml
- Postman collection: https://cloud.langfuse.com/postman-collection.json
error-discrimination:
strategy: status-code
auth: basic
+44 -11
View File
@@ -4,18 +4,42 @@ imports:
pagination: ./utils/pagination.yml
service:
auth: true
base-path: /api/public
base-path: /api/public/v2
endpoints:
get:
docs: Get a prompt
method: GET
path: /prompts/{promptName}
path-parameters:
promptName:
type: string
docs: The name of the prompt
request:
name: GetPromptRequest
query-parameters:
version:
type: optional<integer>
docs: Version of the prompt to be retrieved.
label:
type: optional<string>
docs: Label of the prompt to be retrieved. Defaults to "production" if no label or version is set.
response: Prompt
list:
docs: Get a list of prompt names with versions and labels
method: GET
path: /prompts
request:
name: GetParameterRequest
name: ListPromptsMetaRequest
query-parameters:
name: string
version: optional<integer>
response: Prompt
name: optional<string>
label: optional<string>
tag: optional<string>
page: optional<integer>
limit: optional<integer>
response: PromptMetaListResponse
create:
docs: Create a prompt
method: POST
@@ -24,6 +48,18 @@ service:
response: Prompt
types:
PromptMetaListResponse:
properties:
data: list<PromptMeta>
meta: pagination.MetaResponse
PromptMeta:
properties:
name: string
versions: list<integer>
labels: list<string>
tags: list<string>
CreatePromptRequest:
union:
chat: CreateChatPromptRequest
@@ -32,20 +68,16 @@ types:
CreateChatPromptRequest:
properties:
name: string
isActive:
docs: Should the prompt be promoted to production immediately?
type: boolean
prompt: list<ChatMessage>
config: optional<unknown>
labels: optional<list<string>>
CreateTextPromptRequest:
properties:
name: string
isActive:
docs: Should the prompt be promoted to production immediately?
type: boolean
prompt: string
config: optional<unknown>
labels: optional<list<string>>
Prompt:
union:
@@ -57,6 +89,7 @@ types:
name: string
version: integer
config: unknown
labels: list<string>
ChatMessage:
properties:
+1 -1
View File
@@ -33,7 +33,7 @@ groups:
version: 0.0.45
output:
location: local-file-system
path: ../../../generated/postman
path: ../../../web/generated/postman
# published:
# generators:
# - name: fernapi/fern-python-sdk
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "2.36.0",
"version": "2.37.2",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
+2 -1
View File
@@ -334,9 +334,10 @@ export type Prompt = {
name: string;
version: number;
type: Generated<string>;
is_active: boolean;
is_active: boolean | null;
config: Generated<unknown>;
tags: Generated<string[]>;
labels: Generated<string[]>;
};
export type Score = {
id: string;
@@ -0,0 +1,16 @@
BEGIN;
-- Step 1: Alter the 'prompts' table to add the 'labels' column
ALTER TABLE prompts
ADD COLUMN labels TEXT[] DEFAULT ARRAY[]::TEXT[];
-- Step 2: Update the 'labels' column to include 'production' for active prompts
UPDATE prompts
SET labels = array_append(labels, 'production')
WHERE is_active = TRUE;
-- Step 3: Drop the required constraint on 'is_active' column.
ALTER TABLE prompts
ALTER COLUMN is_active DROP NOT NULL;
COMMIT;
@@ -0,0 +1,15 @@
UPDATE
prompts
SET
labels = array_append(labels, 'latest')
WHERE
id = (
SELECT
id
FROM
prompts AS p2
WHERE
p2.name = prompts.name
ORDER BY
created_at DESC
LIMIT 1);
+2 -1
View File
@@ -553,9 +553,10 @@ model Prompt {
name String
version Int
type String @default("text")
isActive Boolean @map("is_active")
isActive Boolean? @map("is_active") // Deprecated. To be removed once 'production' labels work as expected.
config Json @default("{}")
tags String[] @default([])
labels String[] @default([])
Observation Observation[]
@@unique([projectId, name, version])
+9 -11
View File
@@ -72,7 +72,7 @@ async function main() {
name: "summary-prompt",
project: { connect: { id: seedProjectId } },
prompt: "prompt {{variable}} {{anotherVariable}}",
isActive: true,
labels: ["production"],
version: 1,
createdBy: "user-1",
},
@@ -764,7 +764,7 @@ async function generatePrompts(project: Project) {
prompt: "Prompt 1 content",
name: "Prompt 1",
version: 1,
isActive: true,
labels: ["production"],
},
{
id: `prompt-${v4()}`,
@@ -773,7 +773,7 @@ async function generatePrompts(project: Project) {
prompt: "Prompt 2 content",
name: "Prompt 2",
version: 1,
isActive: true,
labels: ["production"],
},
{
id: `prompt-${v4()}`,
@@ -782,7 +782,7 @@ async function generatePrompts(project: Project) {
prompt: "Prompt 3 content",
name: "Prompt 3 by API",
version: 1,
isActive: true,
labels: ["production"],
},
{
id: `prompt-${v4()}`,
@@ -791,7 +791,7 @@ async function generatePrompts(project: Project) {
prompt: "Prompt 4 content",
name: "Prompt 4",
version: 1,
isActive: true,
labels: ["production"],
tags: ["tag1", "tag2"],
},
];
@@ -812,7 +812,7 @@ async function generatePrompts(project: Project) {
prompt: prompt.prompt,
name: prompt.name,
version: prompt.version,
isActive: prompt.isActive,
labels: prompt.labels,
tags: prompt.tags,
},
update: {
@@ -833,7 +833,6 @@ async function generatePrompts(project: Project) {
temperature: 0.7,
},
version: 1,
isActive: false,
},
{
id: `prompt-${v4()}`,
@@ -846,7 +845,7 @@ async function generatePrompts(project: Project) {
topP: 0.9,
},
version: 2,
isActive: true,
labels: ["production"],
},
{
id: `prompt-${v4()}`,
@@ -860,7 +859,6 @@ async function generatePrompts(project: Project) {
frequencyPenalty: 0.5,
},
version: 3,
isActive: false,
},
];
@@ -881,7 +879,7 @@ async function generatePrompts(project: Project) {
name: version.name,
config: version.config,
version: version.version,
isActive: version.isActive,
labels: version.labels,
},
update: {
id: version.id,
@@ -910,7 +908,7 @@ async function generatePrompts(project: Project) {
prompt: `${promptName} version ${i} content`,
name: promptName,
version: i,
isActive: i === 20,
labels: i === 20 ? ["production"] : [],
},
update: {
id: promptId,
+1 -1
View File
@@ -7,7 +7,7 @@
"outDir": "./dist",
"types": ["node"],
"target": "ES2020",
"rootDir": ".",
"rootDir": "."
},
"include": ["."],
"exclude": ["node_modules", "dist"]
+147 -14
View File
@@ -6,13 +6,22 @@ info:
## Authentication
Authenticate with the API using Basic Auth, get API keys in the project
settings:
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/openapi-server.yml
- Postman collection: https://cloud.langfuse.com/postman-collection.json
paths:
/api/public/dataset-items:
post:
@@ -717,19 +726,100 @@ paths:
application/json:
schema: {}
security: *ref_0
/api/public/prompts:
/api/public/v2/prompts/{promptName}:
get:
description: Get a prompt
operationId: prompts_get
tags:
- Prompts
parameters:
- name: name
in: query
- name: promptName
in: path
description: The name of the prompt
required: true
schema:
type: string
- name: version
in: query
description: Version of the prompt to be retrieved.
required: false
schema:
type: integer
nullable: true
- name: label
in: query
description: >-
Label of the prompt to be retrieved. Defaults to "production" if no
label or version is set.
required: false
schema:
type: string
nullable: true
responses:
'200':
description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/Prompt'
'400':
description: ''
content:
application/json:
schema: {}
'401':
description: ''
content:
application/json:
schema: {}
'403':
description: ''
content:
application/json:
schema: {}
'404':
description: ''
content:
application/json:
schema: {}
'405':
description: ''
content:
application/json:
schema: {}
security: *ref_0
/api/public/v2/prompts:
get:
description: Get a list of prompt names with versions and labels
operationId: prompts_list
tags:
- Prompts
parameters:
- name: name
in: query
required: false
schema:
type: string
nullable: true
- name: label
in: query
required: false
schema:
type: string
nullable: true
- name: tag
in: query
required: false
schema:
type: string
nullable: true
- name: page
in: query
required: false
schema:
type: integer
nullable: true
- name: limit
in: query
required: false
schema:
@@ -741,7 +831,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/Prompt'
$ref: '#/components/schemas/PromptMetaListResponse'
'400':
description: ''
content:
@@ -2479,6 +2569,42 @@ components:
required:
- id
- name
PromptMetaListResponse:
title: PromptMetaListResponse
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/PromptMeta'
meta:
$ref: '#/components/schemas/utilsMetaResponse'
required:
- data
- meta
PromptMeta:
title: PromptMeta
type: object
properties:
name:
type: string
versions:
type: array
items:
type: integer
labels:
type: array
items:
type: string
tags:
type: array
items:
type: string
required:
- name
- versions
- labels
- tags
CreatePromptRequest:
title: CreatePromptRequest
oneOf:
@@ -2510,18 +2636,19 @@ components:
properties:
name:
type: string
isActive:
type: boolean
description: Should the prompt be promoted to production immediately?
prompt:
type: array
items:
$ref: '#/components/schemas/ChatMessage'
config:
nullable: true
labels:
type: array
items:
type: string
nullable: true
required:
- name
- isActive
- prompt
CreateTextPromptRequest:
title: CreateTextPromptRequest
@@ -2529,16 +2656,17 @@ components:
properties:
name:
type: string
isActive:
type: boolean
description: Should the prompt be promoted to production immediately?
prompt:
type: string
config:
nullable: true
labels:
type: array
items:
type: string
nullable: true
required:
- name
- isActive
- prompt
Prompt:
title: Prompt
@@ -2574,10 +2702,15 @@ components:
version:
type: integer
config: {}
labels:
type: array
items:
type: string
required:
- name
- version
- config
- labels
ChatMessage:
title: ChatMessage
type: object
@@ -2,7 +2,7 @@
"info": {
"name": "Langfuse",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"description": "## Authentication\n\nAuthenticate with the API using Basic Auth, get API keys in the project settings:\n\n- username: Langfuse Public Key\n- password: Langfuse Secret Key"
"description": "## Authentication\n\nAuthenticate with the API using [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication), get API keys in the project settings:\n\n- username: Langfuse Public Key\n- password: Langfuse Secret Key\n\n## Exports\n\n- OpenAPI spec: https://cloud.langfuse.com/openapi-server.yml\n- Postman collection: https://cloud.langfuse.com/postman-collection.json"
},
"variable": [
{
@@ -585,13 +585,58 @@
"request": {
"description": "Get a prompt",
"url": {
"raw": "{{baseUrl}}/api/public/prompts?name=&version=",
"raw": "{{baseUrl}}/api/public/v2/prompts/:promptName?version=&label=",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"public",
"v2",
"prompts",
":promptName"
],
"query": [
{
"key": "version",
"value": "",
"description": "Version of the prompt to be retrieved."
},
{
"key": "label",
"value": "",
"description": "Label of the prompt to be retrieved. Defaults to \"production\" if no label or version is set."
}
],
"variable": [
{
"key": "promptName",
"value": "",
"description": "The name of the prompt"
}
]
},
"header": [],
"method": "GET",
"auth": null,
"body": null
},
"response": []
},
{
"_type": "endpoint",
"name": "List",
"request": {
"description": "Get a list of prompt names with versions and labels",
"url": {
"raw": "{{baseUrl}}/api/public/v2/prompts?name=&label=&tag=&page=&limit=",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"public",
"v2",
"prompts"
],
"query": [
@@ -601,7 +646,22 @@
"description": null
},
{
"key": "version",
"key": "label",
"value": "",
"description": null
},
{
"key": "tag",
"value": "",
"description": null
},
{
"key": "page",
"value": "",
"description": null
},
{
"key": "limit",
"value": "",
"description": null
}
@@ -621,13 +681,14 @@
"request": {
"description": "Create a prompt",
"url": {
"raw": "{{baseUrl}}/api/public/prompts",
"raw": "{{baseUrl}}/api/public/v2/prompts",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"public",
"v2",
"prompts"
],
"query": [],
@@ -638,7 +699,7 @@
"auth": null,
"body": {
"mode": "raw",
"raw": "{\n \"type\": \"chat\",\n \"name\": \"example\",\n \"isActive\": true,\n \"prompt\": [\n {\n \"role\": \"example\",\n \"content\": \"example\"\n }\n ],\n \"config\": \"UNKNOWN\"\n}",
"raw": "{\n \"type\": \"chat\",\n \"name\": \"example\",\n \"prompt\": [\n {\n \"role\": \"example\",\n \"content\": \"example\"\n }\n ],\n \"config\": \"UNKNOWN\",\n \"labels\": [\n \"example\"\n ]\n}",
"options": {
"raw": {
"language": "json"
+2 -2
View File
@@ -1,13 +1,13 @@
{
"name": "web",
"version": "2.36.0",
"version": "2.37.2",
"private": true,
"license": "MIT",
"engines": {
"node": ">=20"
},
"scripts": {
"copy-openapi-spec": "cp generated/openapi-client/openapi.yml public/openapi-client.yml && cp generated/openapi-server/openapi.yml public/openapi-server.yml",
"copy-openapi-spec": "cp generated/openapi-client/openapi.yml public/openapi-client.yml && cp generated/openapi-server/openapi.yml public/openapi-server.yml && cp generated/postman/collection.json public/postman-collection.json",
"build": "pnpm run copy-openapi-spec && INLINE_RUNTIME_CHUNK=false dotenv -e ../.env -- next build",
"dev": "dotenv -e ../.env -- next dev",
"lint": "dotenv -e ../.env -- next lint",
+2 -1
View File
@@ -1 +1,2 @@
# openapi*.yml
openapi*.yml
postman-collection.json
-104
View File
@@ -1,104 +0,0 @@
openapi: 3.0.1
info:
title: langfuse
version: ''
paths:
/api/public/scores:
post:
description: Add a score to the database, upserts on id
operationId: score_create
tags:
- Score
parameters: []
responses:
'200':
description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/Score'
'400':
description: ''
content:
application/json:
schema:
type: string
'401':
description: ''
content:
application/json:
schema:
type: string
'403':
description: ''
content:
application/json:
schema:
type: string
'405':
description: ''
content:
application/json:
schema:
type: string
security:
- BearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateScoreRequest'
components:
schemas:
CreateScoreRequest:
title: CreateScoreRequest
type: object
properties:
id:
type: string
traceId:
type: string
name:
type: string
value:
type: number
format: double
observationId:
type: string
comment:
type: string
required:
- traceId
- name
- value
Score:
title: Score
type: object
properties:
id:
type: string
traceId:
type: string
name:
type: string
value:
type: number
format: double
observationId:
type: string
timestamp:
type: string
format: date-time
comment:
type: string
required:
- id
- traceId
- name
- value
- timestamp
securitySchemes:
BearerAuth:
type: http
scheme: bearer
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -18,9 +18,9 @@ if (process.env.NEXT_PUBLIC_SENTRY_DSN)
samplingContext.request.url &&
samplingContext.request.url.includes("api/trpc")
) {
return 0.5;
return 0.3;
}
return 0.15;
return 0.1;
},
profilesSampleRate: 0.2, // Profiling sample rate is relative to tracesSampleRate
@@ -5,10 +5,10 @@ import { makeAPICall, pruneDatabase } from "@/src/__tests__/test-utils";
import { v4 as uuidv4, v4 } from "uuid";
import { type Prompt } from "@langfuse/shared";
import {
PromptSchema,
LegacyPromptSchema,
PromptType,
type ValidatedPrompt,
} from "@/src/features/prompts/server/validation";
type LegacyValidatedPrompt,
} from "@/src/features/prompts/server/utils/validation";
describe("/api/public/prompts API Endpoint", () => {
beforeEach(async () => await pruneDatabase());
@@ -21,7 +21,7 @@ describe("/api/public/prompts API Endpoint", () => {
id: promptId,
name: "prompt-name",
prompt: "prompt",
isActive: true,
labels: ["production"],
version: 1,
config: {
temperature: 0.1,
@@ -51,6 +51,7 @@ describe("/api/public/prompts API Endpoint", () => {
expect(fetchedObservations.body.type).toBe("text");
expect(fetchedObservations.body.version).toBe(1);
expect(fetchedObservations.body.isActive).toBe(true);
expect(fetchedObservations.body.labels).toEqual(["production"]);
expect(fetchedObservations.body.createdBy).toBe("user-1");
expect(fetchedObservations.body.config).toEqual({ temperature: 0.1 });
expect(fetchedObservations.body.tags).toEqual([]);
@@ -64,7 +65,7 @@ describe("/api/public/prompts API Endpoint", () => {
id: promptId,
name: "prompt + name",
prompt: "prompt",
isActive: true,
labels: ["production"],
version: 1,
config: {
temperature: 0.1,
@@ -94,6 +95,7 @@ describe("/api/public/prompts API Endpoint", () => {
expect(fetchedObservations.body.type).toBe("text");
expect(fetchedObservations.body.version).toBe(1);
expect(fetchedObservations.body.isActive).toBe(true);
expect(fetchedObservations.body.labels).toEqual(["production"]);
expect(fetchedObservations.body.createdBy).toBe("user-1");
expect(fetchedObservations.body.config).toEqual({ temperature: 0.1 });
expect(fetchedObservations.body.tags).toEqual([]);
@@ -107,7 +109,7 @@ describe("/api/public/prompts API Endpoint", () => {
id: promptId,
name: "prompt-name",
prompt: "prompt",
isActive: false,
labels: [],
version: 1,
config: {
temperature: 0.1,
@@ -137,7 +139,7 @@ describe("/api/public/prompts API Endpoint", () => {
id: promptId,
name: "prompt-name",
prompt: "prompt-one",
isActive: false,
labels: [],
version: 1,
config: {
temperature: 0.1,
@@ -154,7 +156,7 @@ describe("/api/public/prompts API Endpoint", () => {
id: promptTwoId,
name: "prompt-name",
prompt: "prompt",
isActive: true,
labels: ["production"],
version: 2,
config: {
temperature: 0.2,
@@ -183,68 +185,192 @@ describe("/api/public/prompts API Endpoint", () => {
expect(fetchedObservations.body.prompt).toBe("prompt-one");
expect(fetchedObservations.body.version).toBe(1);
expect(fetchedObservations.body.isActive).toBe(false);
expect(fetchedObservations.body.labels).toEqual([]);
expect(fetchedObservations.body.createdBy).toBe("user-1");
expect(fetchedObservations.body.config).toEqual({ temperature: 0.1 });
});
it("should fetch active prompt when multiple exist", async () => {
const promptIdOne = uuidv4();
const promptIdTwo = uuidv4();
await prisma.prompt.create({
data: {
id: promptIdOne,
name: "prompt-name",
prompt: "prompt",
isActive: false,
version: 1,
config: {
temperature: 0.1,
},
project: {
connect: { id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a" },
},
createdBy: "user-1",
// First prompt is activated
const prompt1 = await makeAPICall("POST", "/api/public/prompts", {
name: "prompt-name",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
prompt: "prompt1",
isActive: true,
version: 1,
config: {
temperature: 0.1,
},
createdBy: "user-1",
});
await prisma.prompt.create({
data: {
id: promptIdTwo,
name: "prompt-name",
prompt: "prompt",
isActive: true,
version: 2,
config: {
temperature: 0.2,
},
project: {
connect: { id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a" },
},
createdBy: "user-1",
// Second prompt also activated
const prompt2 = await makeAPICall("POST", "/api/public/prompts", {
name: "prompt-name",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
prompt: "prompt2",
labels: ["production"],
isActive: true,
version: 2,
config: {
temperature: 0.2,
},
createdBy: "user-1",
});
const fetchedObservations = await makeAPICall(
// Third prompt is deactivated
const prompt3 = await makeAPICall("POST", "/api/public/prompts", {
name: "prompt-name",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
prompt: "prompt3",
labels: [], // This should be ignored
isActive: false,
version: 3,
config: {
temperature: 0.3,
},
createdBy: "user-1",
});
// Expect the second prompt to be fetched
const fetchedProductionPrompt = await makeAPICall(
"GET",
"/api/public/prompts?name=prompt-name",
undefined,
);
expect(fetchedObservations.status).toBe(200);
expect(fetchedProductionPrompt.status).toBe(200);
if (!isPrompt(fetchedObservations.body)) {
if (!isPrompt(fetchedProductionPrompt.body)) {
throw new Error("Expected body to be a prompt");
}
expect(fetchedObservations.body.id).toBe(promptIdTwo);
expect(fetchedObservations.body.name).toBe("prompt-name");
expect(fetchedObservations.body.prompt).toBe("prompt");
expect(fetchedObservations.body.type).toBe("text");
expect(fetchedObservations.body.version).toBe(2);
expect(fetchedObservations.body.isActive).toBe(true);
expect(fetchedObservations.body.createdBy).toBe("user-1");
expect(fetchedObservations.body.config).toEqual({ temperature: 0.2 });
// @ts-expect-error
expect(fetchedProductionPrompt.body.id).toBe(prompt2.body.id);
expect(fetchedProductionPrompt.body.name).toBe("prompt-name");
expect(fetchedProductionPrompt.body.prompt).toBe("prompt2");
expect(fetchedProductionPrompt.body.type).toBe("text");
expect(fetchedProductionPrompt.body.version).toBe(2);
expect(fetchedProductionPrompt.body.isActive).toBe(true);
expect(fetchedProductionPrompt.body.labels).toEqual(["production"]);
expect(fetchedProductionPrompt.body.createdBy).toBe("API");
expect(fetchedProductionPrompt.body.config).toEqual({ temperature: 0.2 });
// Expect the first prompt to be deactivated
const fetchedOldProductionPrompt = await makeAPICall(
"GET",
"/api/public/prompts?name=prompt-name&version=1",
undefined,
);
expect(fetchedOldProductionPrompt.status).toBe(200);
if (!isPrompt(fetchedOldProductionPrompt.body)) {
throw new Error("Expected body to be a prompt");
}
// @ts-expect-error
expect(fetchedOldProductionPrompt.body.id).toBe(prompt1.body.id);
expect(fetchedOldProductionPrompt.body.name).toBe("prompt-name");
expect(fetchedOldProductionPrompt.body.prompt).toBe("prompt1");
expect(fetchedOldProductionPrompt.body.type).toBe("text");
expect(fetchedOldProductionPrompt.body.version).toBe(1);
expect(fetchedOldProductionPrompt.body.isActive).toBe(false);
expect(fetchedOldProductionPrompt.body.labels).toEqual([]);
expect(fetchedOldProductionPrompt.body.createdBy).toBe("API");
expect(fetchedOldProductionPrompt.body.config).toEqual({
temperature: 0.1,
});
});
it("should correctly handle overwriting labels", async () => {
// First prompt has multiple labels
const prompt1 = await makeAPICall("POST", "/api/public/prompts", {
name: "prompt-name",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
prompt: "prompt1",
labels: ["production", "staging", "development"],
isActive: true,
version: 1,
config: {
temperature: 0.1,
},
createdBy: "user-1",
});
// Second prompt overwrites production and staging label
const prompt2 = await makeAPICall("POST", "/api/public/prompts", {
name: "prompt-name",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
prompt: "prompt2",
labels: ["production", "production", "staging"], // Should be deduped
isActive: true,
version: 2,
config: {
temperature: 0.2,
},
createdBy: "user-1",
});
// Third prompt overwrites staging label
const prompt3 = await makeAPICall("POST", "/api/public/prompts", {
name: "prompt-name",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
prompt: "prompt3",
labels: ["staging"],
isActive: false,
version: 3,
config: {
temperature: 0.3,
},
createdBy: "user-1",
});
// Expect the second prompt to be fetched as default production prompt
const fetchedProductionPrompt = await makeAPICall(
"GET",
"/api/public/prompts?name=prompt-name",
undefined,
);
expect(fetchedProductionPrompt.status).toBe(200);
if (!isPrompt(fetchedProductionPrompt.body)) {
throw new Error("Expected body to be a prompt");
}
// @ts-expect-error
expect(fetchedProductionPrompt.body.id).toBe(prompt2.body.id);
expect(fetchedProductionPrompt.body.labels).toEqual(["production"]); // Only production label should be present
// Expect the first prompt to have only development label
const fetchedFirstPrompt = await makeAPICall(
"GET",
"/api/public/prompts?name=prompt-name&version=1",
undefined,
);
expect(fetchedFirstPrompt.status).toBe(200);
if (!isPrompt(fetchedFirstPrompt.body)) {
throw new Error("Expected body to be a prompt");
}
// @ts-expect-error
expect(fetchedFirstPrompt.body.id).toBe(prompt1.body.id);
expect(fetchedFirstPrompt.body.labels).toEqual(["development"]);
// Expect the third prompt to have only staging label
const fetchedThirdPrompt = await makeAPICall(
"GET",
"/api/public/prompts?name=prompt-name&version=3",
undefined,
);
expect(fetchedThirdPrompt.status).toBe(200);
if (!isPrompt(fetchedThirdPrompt.body)) {
throw new Error("Expected body to be a prompt");
}
// @ts-expect-error
expect(fetchedThirdPrompt.body.id).toBe(prompt3.body.id);
expect(fetchedThirdPrompt.body.labels).toEqual(["staging", "latest"]);
});
it("should create and fetch a prompt", async () => {
@@ -275,6 +401,7 @@ describe("/api/public/prompts API Endpoint", () => {
expect(fetchedObservations.body.type).toBe("text");
expect(fetchedObservations.body.version).toBe(1);
expect(fetchedObservations.body.isActive).toBe(true);
expect(fetchedObservations.body.labels).toEqual(["production", "latest"]);
expect(fetchedObservations.body.createdBy).toBe("API");
expect(fetchedObservations.body.config).toEqual({ temperature: 0.1 });
});
@@ -290,7 +417,7 @@ describe("/api/public/prompts API Endpoint", () => {
id: promptId,
name: "prompt-name",
prompt: "prompt",
isActive: true,
labels: ["production"],
version: 1,
project: {
connect: { id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a" },
@@ -353,7 +480,7 @@ describe("/api/public/prompts API Endpoint", () => {
id: promptId,
name: "prompt-name",
prompt: "prompt",
isActive: true,
labels: ["production"],
version: 1,
project: {
connect: { id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a" },
@@ -428,6 +555,7 @@ describe("/api/public/prompts API Endpoint", () => {
expect(fetchedObservations.body.type).toBe("text");
expect(fetchedObservations.body.version).toBe(1);
expect(fetchedObservations.body.isActive).toBe(true);
expect(fetchedObservations.body.labels).toEqual(["production", "latest"]);
expect(fetchedObservations.body.createdBy).toBe("API");
expect(fetchedObservations.body.config).toEqual({});
});
@@ -461,6 +589,7 @@ describe("/api/public/prompts API Endpoint", () => {
expect(validatedPrompt.type).toBe("chat");
expect(validatedPrompt.version).toBe(1);
expect(validatedPrompt.isActive).toBe(true);
expect(validatedPrompt.labels).toEqual(["production", "latest"]);
expect(validatedPrompt.createdBy).toBe("API");
expect(validatedPrompt.config).toEqual({});
});
@@ -589,6 +718,7 @@ describe("/api/public/prompts API Endpoint", () => {
expect(validatedPrompt.type).toBe("chat");
expect(validatedPrompt.version).toBe(1);
expect(validatedPrompt.isActive).toBe(true);
expect(validatedPrompt.labels).toEqual(["production", "latest"]);
expect(validatedPrompt.createdBy).toBe("API");
expect(validatedPrompt.config).toEqual({});
@@ -606,9 +736,11 @@ describe("/api/public/prompts API Endpoint", () => {
});
});
const isPrompt = (x: unknown): x is Prompt => {
type PromptWithIsActive = Prompt & { isActive: boolean };
const isPrompt = (x: unknown): x is PromptWithIsActive => {
if (typeof x !== "object" || x === null) return false;
const prompt = x as Prompt;
const prompt = x as PromptWithIsActive;
return (
typeof prompt.id === "string" &&
typeof prompt.name === "string" &&
@@ -622,7 +754,9 @@ const isPrompt = (x: unknown): x is Prompt => {
);
};
const validatePrompt = (obj: Record<string, unknown>): ValidatedPrompt => {
const validatePrompt = (
obj: Record<string, unknown>,
): LegacyValidatedPrompt => {
Object.keys(obj).forEach((key) => {
obj[key] =
key === "createdAt" || key === "updatedAt"
@@ -630,5 +764,5 @@ const validatePrompt = (obj: Record<string, unknown>): ValidatedPrompt => {
: obj[key];
});
return PromptSchema.parse(obj);
return LegacyPromptSchema.parse(obj);
};
File diff suppressed because it is too large Load Diff
-1
View File
@@ -161,7 +161,6 @@ export default function Layout(props: PropsWithChildren) {
const hideNavigation =
session.status === "unauthenticated" ||
projects.length === 0 ||
pathsWithoutNavigation.includes(router.pathname) ||
router.pathname.startsWith("/public/");
if (hideNavigation)
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v2.36.0";
export const VERSION = "v2.37.2";
@@ -1,5 +1,5 @@
import { usePostHog } from "posthog-js/react";
import { useCallback, useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { Input } from "@/src/components/ui/input";
@@ -14,7 +14,7 @@ import {
FormMessage,
} from "@/src/components/ui/form";
import { Textarea } from "@/src/components/ui/textarea";
import { type RouterOutputs, api } from "@/src/utils/api";
import { api } from "@/src/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { extractVariables, getIsCharOrUnderscore } from "@/src/utils/string";
import router from "next/router";
@@ -13,7 +13,7 @@ import { v4 as uuidv4 } from "uuid";
import { createEmptyMessage } from "@/src/components/ChatMessages/utils/createEmptyMessage";
import useCommandEnter from "@/src/ee/features/playground/page/hooks/useCommandEnter";
import { ChatMessageListSchema } from "@/src/features/prompts/components/NewPromptForm/validation";
import { PromptType } from "@/src/features/prompts/server/validation";
import { PromptType } from "@/src/features/prompts/server/utils/validation";
import useProjectIdFromURL from "@/src/hooks/useProjectIdFromURL";
import { api } from "@/src/utils/api";
import { extractVariables } from "@/src/utils/string";
@@ -24,7 +24,7 @@ import { Textarea } from "@/src/components/ui/textarea";
import {
type CreatePromptTRPCType,
PromptType,
} from "@/src/features/prompts/server/validation";
} from "@/src/features/prompts/server/utils/validation";
import useProjectIdFromURL from "@/src/hooks/useProjectIdFromURL";
import { api } from "@/src/utils/api";
import { extractVariables, getIsCharOrUnderscore } from "@/src/utils/string";
@@ -42,6 +42,7 @@ import Link from "next/link";
import { ArrowTopRightIcon } from "@radix-ui/react-icons";
import { PromptDescription } from "@/src/features/prompts/components/prompt-description";
import { JsonEditor } from "@/src/components/json-editor";
import { PRODUCTION_LABEL } from "@/src/features/prompts/constants";
type NewPromptFormProps = {
initialPrompt?: Prompt | null;
@@ -122,6 +123,7 @@ export const NewPromptForm: React.FC<NewPromptFormProps> = (props) => {
type,
prompt: chatPrompt,
config: JSON.parse(values.config),
labels: values.isActive ? [PRODUCTION_LABEL] : [],
};
} else {
newPrompt = {
@@ -130,6 +132,7 @@ export const NewPromptForm: React.FC<NewPromptFormProps> = (props) => {
type,
prompt: textPrompt,
config: JSON.parse(values.config),
labels: values.isActive ? [PRODUCTION_LABEL] : [],
};
}
@@ -154,6 +157,13 @@ export const NewPromptForm: React.FC<NewPromptFormProps> = (props) => {
if (!isNewPrompt) {
form.setError("name", { message: "Prompt name already exist." });
} else if (currentName === "new") {
form.setError("name", { message: "Prompt name cannot be 'new'" });
} else if (currentName && !/^[a-zA-Z0-9_\-.]+$/.test(currentName)) {
form.setError("name", {
message:
"Name must be alphanumeric with optional underscores, hyphens, or periods",
});
} else {
form.clearErrors("name");
}
@@ -308,12 +318,11 @@ export const NewPromptForm: React.FC<NewPromptFormProps> = (props) => {
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Activate prompt</FormLabel>
<FormLabel>Serve prompt as default to SDKs</FormLabel>
</div>
{currentIsActive ? (
<div className="text-xs text-gray-500">
Activating the prompt will make it available to the SDKs
immediately.
This makes the prompt available to the SDKs immediately.
</div>
) : null}
</FormItem>
@@ -1,5 +1,5 @@
import { z } from "zod";
import { PromptType } from "@/src/features/prompts/server/validation";
import { PromptType } from "@/src/features/prompts/server/utils/validation";
import { ChatMessageRole } from "@langfuse/shared";
const ChatMessageSchema = z.object({
@@ -0,0 +1,69 @@
import React from "react";
import { PlusIcon } from "lucide-react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/src/components/ui/form";
import { Button } from "@/src/components/ui/button";
import { Input } from "@/src/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { PromptLabelSchema } from "@/src/features/prompts/server/utils/validation";
const AddLabelFormSchema = z.object({
newLabel: PromptLabelSchema,
});
type AddLabelFromSchemaType = z.infer<typeof AddLabelFormSchema>;
export const AddLabelForm = (props: {
setLabels: React.Dispatch<React.SetStateAction<string[]>>;
setSelectedLabels: React.Dispatch<React.SetStateAction<string[]>>;
onAddLabel: () => void;
}) => {
const form = useForm<AddLabelFromSchemaType>({
resolver: zodResolver(AddLabelFormSchema),
defaultValues: {
newLabel: "",
},
});
const onSubmit = () => {
const newLabel = form.getValues().newLabel;
props.setLabels((prev) => [...prev, newLabel]);
props.setSelectedLabels((prev) => [...new Set([...prev, newLabel])]);
props.onAddLabel();
form.reset();
};
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="my-3 flex flex-row space-x-2 align-top"
>
<FormField
control={form.control}
name="newLabel"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input placeholder="New label" {...field} />
</FormControl>
<FormMessage className="text-xs" />
</FormItem>
)}
/>
<Button type="submit" size="icon" variant="outline">
<PlusIcon className="h-5 w-5" />
</Button>
</form>
</Form>
);
};
@@ -0,0 +1,41 @@
import React from "react";
import { CircleCheckIcon, CircleIcon } from "lucide-react";
import { Button } from "@/src/components/ui/button";
import { cn } from "@/src/utils/tailwind";
export const LabelCommandItem = (props: {
label: string;
selectedLabels: string[];
setSelectedLabels: React.Dispatch<React.SetStateAction<string[]>>;
}) => {
const { label, selectedLabels, setSelectedLabels } = props;
const handleLabelChange = () => {
setSelectedLabels((prev) => {
return prev.includes(label)
? prev.filter((l) => l !== label)
: [...prev, label];
});
};
const isSelected = selectedLabels.includes(label);
return (
<Button
key={label}
type="button"
variant="ghost"
onClick={handleLabelChange}
className={cn(
"w-full justify-start px-2 py-1 text-sm",
isSelected && "font-bold",
)}
>
{isSelected ? (
<CircleCheckIcon className="mr-2 h-4 w-4" />
) : (
<CircleIcon className="mr-2 h-4 w-4 opacity-20" />
)}
{label}
</Button>
);
};
@@ -0,0 +1,191 @@
import React, { useEffect, useState, useRef } from "react";
import { FlagIcon, PlusIcon } from "lucide-react";
import { usePostHog } from "posthog-js/react";
import { Button } from "@/src/components/ui/button";
import {
Command,
CommandGroup,
CommandList,
CommandSeparator,
} from "@/src/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/src/components/ui/popover";
import { useHasAccess } from "@/src/features/rbac/utils/checkAccess";
import useProjectIdFromURL from "@/src/hooks/useProjectIdFromURL";
import { api } from "@/src/utils/api";
import { type Prompt } from "@langfuse/shared";
import { AddLabelForm } from "./AddLabelForm";
import { LabelCommandItem } from "./LabelCommandItem";
import { PRODUCTION_LABEL } from "@/src/features/prompts/constants";
export function SetPromptVersionLabels({ prompt }: { prompt: Prompt }) {
const projectId = useProjectIdFromURL();
const utils = api.useUtils();
const posthog = usePostHog();
const hasAccess = useHasAccess({ projectId, scope: "prompts:CUD" });
const [selectedLabels, setSelectedLabels] = useState<string[]>([]);
const [labels, setLabels] = useState<string[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [isAddingLabel, setIsAddingLabel] = useState(false);
const customLabelScrollRef = useRef<HTMLDivElement | null>(null);
const usedLabelsInProject = api.prompts.allLabels.useQuery(
{
projectId: projectId as string, // Typecast as query is enabled only when projectId is present
},
{ enabled: Boolean(projectId) },
);
// Set initial labels and selected labels
useEffect(() => {
if (isOpen) {
setLabels([
...new Set([...prompt.labels, ...(usedLabelsInProject.data ?? [])]),
]);
setSelectedLabels(prompt.labels);
}
}, [isOpen, prompt.labels, usedLabelsInProject.data]);
const isPromotingToProduction =
!prompt.labels.includes(PRODUCTION_LABEL) &&
selectedLabels.includes(PRODUCTION_LABEL);
const isDemotingFromProduction =
prompt.labels.includes(PRODUCTION_LABEL) &&
!selectedLabels.includes(PRODUCTION_LABEL);
const mutatePromptVersionLabels = api.prompts.setLabels.useMutation({
onSuccess: () => {
void utils.prompts.invalidate();
},
});
const handleSubmitLabels = async () => {
if (!projectId) {
alert("Project ID is missing");
return;
}
await mutatePromptVersionLabels.mutateAsync({
projectId: projectId as string,
promptId: prompt.id,
labels: selectedLabels,
});
posthog.capture("prompt:setLabels", { labels: selectedLabels });
setIsOpen(false);
};
if (!hasAccess) return null;
return (
<Popover
key={prompt.id}
open={isOpen}
onOpenChange={() => {
setIsOpen(!isOpen);
setIsAddingLabel(false);
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
size="icon"
aria-label="Set prompt labels"
title="Set prompt labels"
>
<FlagIcon className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent>
<h2 className="text-md mb-3 font-semibold">Prompt version labels</h2>
<h2 className="mb-3 text-xs">
Use labels to fetch prompts via SDKs. The <strong>production</strong>{" "}
labeled prompt will be served by default.
</h2>
<Command className="mx-0 my-3 px-0">
<CommandList className="max-h-full overflow-hidden">
<CommandSeparator />
<CommandGroup heading="Promote to production?">
<LabelCommandItem
{...{
selectedLabels,
setSelectedLabels,
label: PRODUCTION_LABEL,
}}
/>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Custom labels">
<div
className="max-h-[300px] overflow-y-auto overflow-x-hidden"
ref={customLabelScrollRef}
>
{labels
.filter((l) => l !== PRODUCTION_LABEL)
.map((label) => (
<LabelCommandItem
key={label}
{...{ selectedLabels, setSelectedLabels, label }}
/>
))}
</div>
</CommandGroup>
</CommandList>
<div className="px-1">
{isAddingLabel ? (
<AddLabelForm
{...{
setLabels,
setSelectedLabels,
onAddLabel: () => {
setTimeout(
() =>
customLabelScrollRef.current?.scrollTo({
top: customLabelScrollRef.current?.scrollHeight,
behavior: "smooth",
}),
0,
);
},
}}
/>
) : (
<Button
variant="ghost"
className="mt-2 w-full justify-start px-2 py-1 text-sm font-normal"
onClick={() => setIsAddingLabel(true)}
>
<PlusIcon className="mr-2 h-4 w-4" />
Add custom label
</Button>
)}
</div>
</Command>
<Button
type="button"
variant={
isPromotingToProduction || isDemotingFromProduction
? "destructive"
: "default"
}
loading={mutatePromptVersionLabels.isLoading}
className="w-full"
onClick={handleSubmitLabels}
>
{isPromotingToProduction
? "Save and promote to production"
: isDemotingFromProduction
? "Save and remove from production"
: "Save"}
</Button>
</PopoverContent>
</Popover>
);
}
@@ -1,94 +0,0 @@
import { Button } from "@/src/components/ui/button";
import { useHasAccess } from "@/src/features/rbac/utils/checkAccess";
import { api } from "@/src/utils/api";
import { PlayIcon } from "lucide-react";
import { useState } from "react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/src/components/ui/popover";
import { usePostHog } from "posthog-js/react";
import useProjectIdFromURL from "@/src/hooks/useProjectIdFromURL";
export function PromotePrompt({
promptId,
promptName,
disabled,
variant,
}: {
promptId: string;
promptName: string;
disabled: boolean;
variant?: "ghost" | "outline";
}) {
const projectId = useProjectIdFromURL();
const utils = api.useUtils();
const posthog = usePostHog();
const [isOpen, setIsOpen] = useState(false);
const hasAccess = useHasAccess({ projectId, scope: "prompts:CUD" });
const mutPromotePrompt = api.prompts.promote.useMutation({
onSuccess: () => {
void utils.prompts.invalidate();
},
});
if (!hasAccess) {
return null;
}
return (
<Popover
key={promptId}
open={isOpen}
onOpenChange={() => setIsOpen(!isOpen)}
>
<PopoverTrigger asChild>
<Button
variant={variant ?? "ghost"}
size={variant ? "icon" : "xs"}
disabled={disabled}
aria-label="Promote Prompt to production"
title="Promote Prompt to production"
>
<PlayIcon className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent>
<h2 className="text-md mb-3 font-semibold">Please confirm</h2>
<p className="mb-3 text-sm">
This action promotes the prompt to production. SDKs requesting a
prompt with name{" "}
<code className="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold">
{promptName}
</code>
will receive this prompt. Make sure that the variables match.
</p>
<div className="flex justify-end space-x-4">
<Button
type="button"
variant="destructive"
loading={mutPromotePrompt.isLoading}
onClick={() => {
if (!projectId) {
console.error("Project ID is missing");
return;
}
void mutPromotePrompt.mutateAsync({
promptId,
projectId,
});
posthog.capture("prompt:promote");
setIsOpen(false);
}}
>
Promote to production
</Button>
</div>
</PopoverContent>
</Popover>
);
}
@@ -14,8 +14,7 @@ import { Button } from "@/src/components/ui/button";
import { CodeView, JSONView } from "@/src/components/ui/CodeJsonViewer";
import { DetailPageNav } from "@/src/features/navigate-detail-pages/DetailPageNav";
import { DeletePromptVersion } from "@/src/features/prompts/components/delete-prompt-version";
import { PromotePrompt } from "@/src/features/prompts/components/promote-prompt";
import { PromptType } from "@/src/features/prompts/server/validation";
import { PromptType } from "@/src/features/prompts/server/utils/validation";
import { useHasAccess } from "@/src/features/rbac/utils/checkAccess";
import useProjectIdFromURL from "@/src/hooks/useProjectIdFromURL";
import { api } from "@/src/utils/api";
@@ -23,8 +22,8 @@ import { extractVariables } from "@/src/utils/string";
import { type Prompt } from "@langfuse/shared";
import { ScrollArea } from "@radix-ui/react-scroll-area";
import { TagPromptDetailsPopover } from "@/src/features/tag/components/TagPromptDetailsPopover";
import { PromptHistoryNode } from "./prompt-history";
import { SetPromptVersionLabels } from "@/src/features/prompts/components/SetPromptVersionLabels";
export const PromptDetail = () => {
const projectId = useProjectIdFromURL();
@@ -101,12 +100,7 @@ export const PromptDetail = () => {
]}
actionButtons={
<>
<PromotePrompt
promptId={prompt.id}
promptName={prompt.name}
disabled={prompt.isActive}
variant="outline"
/>
<SetPromptVersionLabels prompt={prompt} />
<Link
href={`/project/${projectId}/playground?promptId=${encodeURIComponent(prompt.id)}`}
@@ -185,6 +179,18 @@ export const PromptDetail = () => {
{prompt.config && JSON.stringify(prompt.config) !== "{}" && (
<JSONView className="mt-5" json={prompt.config} title="Config" />
)}
<p className="mt-6 text-xs text-gray-600">
Fetch prompts via Python or JS/TS SDKs. See{" "}
<a
href="https://langfuse.com/docs/prompts"
className="underline"
target="_blank"
rel="noopener noreferrer"
>
documentation
</a>{" "}
for details.
</p>
</div>
<div className="flex h-screen flex-col">
<div className="text-m px-3 font-medium">
@@ -1,4 +1,5 @@
import { StatusBadge } from "@/src/components/layouts/status-badge";
import { PRODUCTION_LABEL } from "@/src/features/prompts/constants";
import { type RouterOutputs } from "@/src/utils/api";
import { type NextRouter, useRouter } from "next/router";
@@ -9,36 +10,51 @@ const PromptHistoryTraceNode = (props: {
setCurrentPromptVersion: (version: number | undefined) => void;
router: NextRouter;
projectId: string;
}) => (
<div
className={`group mb-2 flex cursor-pointer flex-col gap-1 rounded-sm p-2 hover:bg-gray-50 ${
props.currentPromptVersion === props.prompt.version ? "bg-gray-100" : ""
}`}
onClick={() => {
props.index === 0
? props.setCurrentPromptVersion(undefined)
: props.setCurrentPromptVersion(props.prompt.version);
}}
>
<div className="flex items-center gap-2">
<span className="rounded-sm bg-gray-200 p-1 text-xs">
Version {props.prompt.version}
</span>
{props.prompt.isActive ? <StatusBadge type={"production"} /> : null}
</div>
}) => {
const { prompt } = props;
let badges: JSX.Element[] = prompt.labels
.sort((a, b) =>
a === PRODUCTION_LABEL
? -1
: b === PRODUCTION_LABEL
? 1
: a.localeCompare(b),
)
.map((label) => {
return <StatusBadge type={label} key={label} />;
});
<div className="flex gap-2">
<span className="text-xs text-gray-500">
{props.prompt.createdAt.toLocaleString()}
</span>
return (
<div
className={`group mb-2 flex cursor-pointer flex-col gap-1 rounded-sm p-2 hover:bg-gray-50 ${
props.currentPromptVersion === prompt.version ? "bg-gray-100" : ""
}`}
onClick={() => {
props.index === 0
? props.setCurrentPromptVersion(undefined)
: props.setCurrentPromptVersion(prompt.version);
}}
>
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-sm bg-gray-200 p-1 text-xs">
Version {prompt.version}
</span>
{badges}
</div>
<div className="flex gap-2">
<span className="text-xs text-gray-500">
{prompt.createdAt.toLocaleString()}
</span>
</div>
<div className="flex gap-2">
<span className="text-xs text-gray-500">
by {prompt.creator || prompt.createdBy}
</span>
</div>
</div>
<div className="flex gap-2">
<span className="text-xs text-gray-500">
by {props.prompt.creator || props.prompt.createdBy}
</span>
</div>
</div>
);
);
};
export const PromptHistoryNode = (props: {
prompts: RouterOutputs["prompts"]["allVersions"];
@@ -25,7 +25,7 @@ type PromptTableRow = {
version: number;
id: string;
createdAt: Date;
isActive: boolean;
labels: string[];
type: string;
numberOfObservations: number;
tags: string[];
@@ -189,7 +189,7 @@ export function PromptTable() {
version: item.version,
createdAt: item.createdAt,
type: item.type,
isActive: item.isActive,
labels: item.labels,
numberOfObservations: Number(item.observationCount),
tags: item.tags,
};
@@ -204,6 +204,25 @@ export function PromptTable() {
)}
filterState={filterState}
setFilterState={setFilterState}
actionButtons={
<Link href={`/project/${projectId}/prompts/new`}>
<Button
variant="secondary"
disabled={!hasCUDAccess}
aria-label="Promote Prompt to Production"
>
{hasCUDAccess ? (
<PlusIcon className="-ml-0.5 mr-1.5" aria-hidden="true" />
) : (
<LockIcon
className="-ml-0.5 mr-1.5 h-3 w-3"
aria-hidden="true"
/>
)}
New prompt
</Button>
</Link>
}
/>
<DataTable
columns={promptColumns}
@@ -230,21 +249,6 @@ export function PromptTable() {
state: paginationState,
}}
/>
<Link href={`/project/${projectId}/prompts/new`}>
<Button
variant="secondary"
className="mt-4"
disabled={!hasCUDAccess}
aria-label="Promote Prompt to Production"
>
{hasCUDAccess ? (
<PlusIcon className="-ml-0.5 mr-1.5" aria-hidden="true" />
) : (
<LockIcon className="-ml-0.5 mr-1.5 h-3 w-3" aria-hidden="true" />
)}
New prompt
</Button>
</Link>
</div>
);
}
+2
View File
@@ -0,0 +1,2 @@
export const PRODUCTION_LABEL = "production";
export const LATEST_PROMPT_LABEL = "latest";
@@ -1,24 +1,27 @@
import {
type CreatePromptTRPCType,
PromptType,
} from "@/src/features/prompts/server/validation";
} from "@/src/features/prompts/server/utils/validation";
import { ValidationError } from "@langfuse/shared";
import { jsonSchema } from "@/src/utils/zod";
import { type PrismaClient } from "@langfuse/shared/src/db";
import { LATEST_PROMPT_LABEL } from "@/src/features/prompts/constants";
export type CreatePromptParams = CreatePromptTRPCType & {
createdBy: string;
prisma: PrismaClient;
};
export const createPrompt = async ({
projectId,
name,
prompt,
type = PromptType.Text,
isActive = true,
labels = [],
config,
createdBy,
prisma,
}: CreatePromptTRPCType & {
createdBy: string;
prisma: PrismaClient;
}) => {
}: CreatePromptParams) => {
const latestPrompt = await prisma.prompt.findFirst({
where: { projectId, name },
orderBy: [{ version: "desc" }],
@@ -30,8 +33,14 @@ export const createPrompt = async ({
);
}
const latestActivePrompt = await prisma.prompt.findFirst({
where: { projectId, name, isActive: true },
const finalLabels = [...labels, LATEST_PROMPT_LABEL]; // Newly created prompts are always labeled as 'latest'
const previousLabeledPrompts = await prisma.prompt.findMany({
where: {
projectId,
name,
labels: { hasSome: finalLabels },
},
orderBy: [{ version: "desc" }],
});
@@ -41,7 +50,7 @@ export const createPrompt = async ({
prompt,
name,
createdBy,
isActive,
labels: [...new Set(finalLabels)], // Ensure labels are unique
type,
tags: latestPrompt?.tags,
version: latestPrompt?.version ? latestPrompt.version + 1 : 1,
@@ -50,18 +59,21 @@ export const createPrompt = async ({
},
}),
];
if (latestActivePrompt && isActive)
// If we're creating a new active prompt, we need to deactivate the old one
create.push(
prisma.prompt.update({
where: {
id: latestActivePrompt.id,
},
data: {
isActive: false,
},
}),
);
if (finalLabels.length > 0)
// If we're creating a new labeled prompt, we must remove those labels on previous prompts since labels are unique
previousLabeledPrompts.forEach((prevPrompt) => {
create.push(
prisma.prompt.update({
where: { id: prevPrompt.id },
data: {
labels: prevPrompt.labels.filter(
(prevLabel) => !finalLabels.includes(prevLabel),
),
},
}),
);
});
const [createdPrompt] = await prisma.$transaction(create);
@@ -0,0 +1,106 @@
import { PRODUCTION_LABEL } from "@/src/features/prompts/constants";
import {
LangfuseNotFoundError,
ValidationError,
type Prompt,
} from "@langfuse/shared";
import { prisma } from "@langfuse/shared/src/db";
type GetPromptByNameParams = {
promptName: string;
projectId: string;
version?: number | null;
label?: string;
};
export const getPromptByName = async (
params: GetPromptByNameParams,
): Promise<Prompt | null> => {
const { promptName, projectId, version, label } = params;
if (version && label)
throw new ValidationError("Cannot specify both version and label");
if (version) return getPromptByVersion({ projectId, promptName, version });
if (label) return getPromptByLabel({ projectId, promptName, label });
return getProductionPrompt(params);
};
const getProductionPrompt = async ({
promptName,
projectId,
}: {
promptName: string;
projectId: string;
}): Promise<Prompt> => {
const productionPrompt = await prisma.prompt.findFirst({
where: {
projectId: projectId,
name: promptName,
labels: {
has: PRODUCTION_LABEL,
},
},
});
if (!productionPrompt)
throw new LangfuseNotFoundError(
`No production-labeled prompt found with name '${promptName}' in project ${projectId}`,
);
return productionPrompt;
};
const getPromptByVersion = async ({
promptName,
projectId,
version,
}: {
promptName: string;
projectId: string;
version: number;
}): Promise<Prompt> => {
const prompt = await prisma.prompt.findFirst({
where: {
projectId: projectId,
name: promptName,
version: version,
},
});
if (!prompt)
throw new LangfuseNotFoundError(
`No prompt found with name '${promptName}' in project ${projectId} with version ${version}`,
);
return prompt;
};
const getPromptByLabel = async ({
promptName,
projectId,
label,
}: {
promptName: string;
projectId: string;
label: string;
}): Promise<Prompt> => {
const prompt = await prisma.prompt.findFirst({
where: {
projectId: projectId,
name: promptName,
labels: {
has: label,
},
},
});
if (!prompt)
throw new LangfuseNotFoundError(
`No prompt found with name '${promptName}' in project ${projectId} with label ${label}`,
);
return prompt;
};
@@ -0,0 +1,112 @@
import { type GetPromptsMetaType } from "@/src/features/prompts/server/utils/validation";
import { promptsTableCols } from "@/src/server/api/definitions/promptsTable";
import {
tableColumnsToSqlFilterAndPrefix,
type FilterState,
} from "@langfuse/shared";
import { prisma } from "@langfuse/shared/src/db";
export type GetPromptsMetaParams = GetPromptsMetaType & { projectId: string };
export const getPromptsMeta = async (
params: GetPromptsMetaParams,
): Promise<PromptsMetaResponse> => {
const { projectId, page, limit } = params;
const promptsMeta = (await prisma.$queryRaw`
SELECT
p.name AS name,
p.tags AS tags,
array_agg(DISTINCT p.version) AS versions,
COALESCE(array_agg(DISTINCT label) FILTER (WHERE label IS NOT NULL), '{}'::text[]) AS labels --- COALESCE is necessary to return an empty array if there are no labels and remove NULLs
FROM
prompts p
LEFT JOIN LATERAL unnest(p.labels) AS label ON true
WHERE
p."project_id" = ${projectId}
${getPromptsFilterCondition(params)}
GROUP BY
p.name, p.tags --- tags are the same for all versions of a prompt
ORDER BY
p.name --- necessary for consistent pagination
LIMIT
${limit}
OFFSET
${limit * (page - 1)}
`) as PromptsMeta[];
const [{ count: totalItemsCount }] = (await prisma.$queryRaw`
SELECT COUNT(DISTINCT p.name) AS count
FROM prompts p
WHERE "project_id" = ${projectId}
${getPromptsFilterCondition(params)}
`) as { count: BigInt }[];
const totalItems = Number(totalItemsCount);
const totalPages = Math.ceil(totalItems / limit);
return {
data: promptsMeta,
pagination: { page, limit, totalPages, totalItems },
};
};
type PromptsMeta = {
name: string;
versions: number[];
labels: string[];
tags: string[];
};
export type PromptsMetaResponse = {
data: PromptsMeta[];
pagination: {
page: number;
limit: number;
totalPages: number;
totalItems: number;
};
};
const getPromptsFilterCondition = (params: GetPromptsMetaType) => {
const { name, version, label, tag } = params;
const filters: FilterState = [];
if (name) {
filters.push({
column: "name",
type: "string",
operator: "=",
value: name,
});
}
if (version) {
filters.push({
column: "version",
type: "number",
operator: "=",
value: version,
});
}
if (label) {
filters.push({
column: "labels",
type: "arrayOptions",
operator: "any of",
value: [label],
});
}
if (tag) {
filters.push({
column: "tags",
type: "arrayOptions",
operator: "any of",
value: [tag],
});
}
return tableColumnsToSqlFilterAndPrefix(filters, promptsTableCols, "prompts");
};
@@ -0,0 +1,26 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getPromptByName } from "@/src/features/prompts/server/actions/getPromptByName";
import { GetPromptByNameSchema } from "@/src/features/prompts/server/utils/validation";
import { withMiddlewares } from "@/src/server/utils/withMiddlewares";
import { authorizePromptRequestOrThrow } from "../utils/authorizePromptRequest";
const getPromptNameHandler = async (
req: NextApiRequest,
res: NextApiResponse,
) => {
const authCheck = await authorizePromptRequestOrThrow(req);
const { promptName, version, label } = GetPromptByNameSchema.parse(req.query);
const prompt = await getPromptByName({
promptName: promptName,
projectId: authCheck.scope.projectId,
version,
label,
});
return res.status(200).json(prompt);
};
export const promptNameHandler = withMiddlewares({ GET: getPromptNameHandler });
@@ -0,0 +1,47 @@
import { type NextApiRequest, type NextApiResponse } from "next";
import { createPrompt } from "@/src/features/prompts/server/actions/createPrompt";
import { getPromptsMeta } from "@/src/features/prompts/server/actions/getPromptsMeta";
import {
CreatePromptSchema,
GetPromptsMetaSchema,
} from "@/src/features/prompts/server/utils/validation";
import { withMiddlewares } from "@/src/server/utils/withMiddlewares";
import { prisma } from "@langfuse/shared/src/db";
import { authorizePromptRequestOrThrow } from "../utils/authorizePromptRequest";
const getPromptsHandler = async (req: NextApiRequest, res: NextApiResponse) => {
const authCheck = await authorizePromptRequestOrThrow(req);
const input = GetPromptsMetaSchema.parse(req.query);
const promptsMetadata = await getPromptsMeta({
...input,
projectId: authCheck.scope.projectId,
});
return res.status(200).json(promptsMetadata);
};
const postPromptsHandler = async (
req: NextApiRequest,
res: NextApiResponse,
) => {
const authCheck = await authorizePromptRequestOrThrow(req);
const input = CreatePromptSchema.parse(req.body);
const createdPrompt = await createPrompt({
...input,
config: input.config ?? {},
projectId: authCheck.scope.projectId,
createdBy: "API",
prisma: prisma,
});
return res.status(201).json(createdPrompt);
};
export const promptsHandler = withMiddlewares({
GET: getPromptsHandler,
POST: postPromptsHandler,
});
@@ -1,7 +1,7 @@
import { z } from "zod";
import { auditLog } from "@/src/features/audit-logs/auditLog";
import { CreatePromptTRPCSchema } from "@/src/features/prompts/server/validation";
import { CreatePromptTRPCSchema } from "@/src/features/prompts/server/utils/validation";
import { throwIfNoAccess } from "@/src/features/rbac/utils/checkAccess";
import {
createTRPCRouter,
@@ -10,7 +10,7 @@ import {
import { DB } from "@/src/server/db";
import { type Prompt, Prisma } from "@langfuse/shared/src/db";
import { createPrompt } from "./createPrompt";
import { createPrompt } from "../actions/createPrompt";
import { orderByToPrismaSql } from "@/src/features/orderBy/server/orderByToPrisma";
import { promptsTableCols } from "@/src/server/api/definitions/promptsTable";
import { paginationZod } from "@/src/utils/zod";
@@ -59,7 +59,7 @@ export const promptRouter = createTRPCRouter({
p.type,
p.updated_at as "updatedAt",
p.created_at as "createdAt",
p.is_active as "isActive",
p.labels,
p.tags`,
input.projectId,
filterCondition,
@@ -315,8 +315,14 @@ export const promptRouter = createTRPCRouter({
throw e;
}
}),
promote: protectedProjectProcedure
.input(z.object({ promptId: z.string(), projectId: z.string() }))
setLabels: protectedProjectProcedure
.input(
z.object({
promptId: z.string(),
projectId: z.string(),
labels: z.array(z.string()),
}),
)
.mutation(async ({ input, ctx }) => {
try {
throwIfNoAccess({
@@ -325,31 +331,35 @@ export const promptRouter = createTRPCRouter({
scope: "prompts:CUD",
});
const toBePromotedPrompt = await ctx.prisma.prompt.findUniqueOrThrow({
const toBeLabeledPrompt = await ctx.prisma.prompt.findUniqueOrThrow({
where: {
id: input.promptId,
projectId: input.projectId,
},
});
const newLabels = [...new Set(input.labels)];
await auditLog(
{
session: ctx.session,
resourceType: "prompt",
resourceId: toBePromotedPrompt.id,
action: "promote",
resourceId: toBeLabeledPrompt.id,
action: "setLabel",
after: {
...toBePromotedPrompt,
isActive: true,
...toBeLabeledPrompt,
labels: newLabels,
},
},
ctx.prisma,
);
const latestActivePrompt = await ctx.prisma.prompt.findFirst({
const previousLabeledPrompts = await ctx.prisma.prompt.findMany({
where: {
projectId: input.projectId,
name: toBePromotedPrompt.name,
isActive: true,
name: toBeLabeledPrompt.name,
labels: { hasSome: newLabels },
id: { not: input.promptId },
},
orderBy: [{ version: "desc" }],
});
@@ -357,30 +367,53 @@ export const promptRouter = createTRPCRouter({
const toBeExecuted = [
ctx.prisma.prompt.update({
where: {
id: toBePromotedPrompt.id,
id: toBeLabeledPrompt.id,
projectId: input.projectId,
},
data: {
isActive: true,
labels: newLabels,
},
}),
];
if (latestActivePrompt)
// Remove label from previous labeled prompts
previousLabeledPrompts.forEach((prevPrompt) => {
toBeExecuted.push(
ctx.prisma.prompt.update({
where: {
id: latestActivePrompt.id,
id: prevPrompt.id,
projectId: input.projectId,
},
data: {
isActive: false,
labels: prevPrompt.labels.filter((l) => !newLabels.includes(l)),
},
}),
);
});
await ctx.prisma.$transaction(toBeExecuted);
} catch (e) {
console.log(e);
throw e;
}
}),
allLabels: protectedProjectProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ input, ctx }) => {
throwIfNoAccess({
session: ctx.session,
projectId: input.projectId,
scope: "prompts:read",
});
const labels = await ctx.prisma.$queryRaw<{ label: string }[]>`
SELECT DISTINCT UNNEST(labels) AS label
FROM prompts
WHERE project_id = ${input.projectId}
AND labels IS NOT NULL;
`;
return labels.map((l) => l.label);
}),
updateTags: protectedProjectProcedure
.input(
z.object({
@@ -0,0 +1,15 @@
import { verifyAuthHeaderAndReturnScope } from "@/src/features/public-api/server/apiAuth";
import { type NextApiRequest } from "next";
import { UnauthorizedError, ForbiddenError } from "@langfuse/shared";
export async function authorizePromptRequestOrThrow(req: NextApiRequest) {
const authCheck = await verifyAuthHeaderAndReturnScope(
req.headers.authorization,
);
if (!authCheck.validKey) throw new UnauthorizedError(authCheck.error);
if (authCheck.scope.accessLevel !== "all")
throw new ForbiddenError(
`Access denied - need to use basic auth with secret key to ${req.method} prompts`,
);
return authCheck;
}
@@ -12,9 +12,18 @@ export enum PromptType {
Text = "text",
}
export const PromptLabelSchema = z
.string()
.min(1)
.max(20)
.regex(
/^[a-z0-9_\-.]+$/,
"Label must be lowercase alphanumeric with optional underscores, hyphens, or periods",
);
export const CreateTextPromptSchema = z.object({
name: z.string(),
isActive: z.boolean(),
labels: z.array(PromptLabelSchema).default([]),
type: z.literal(PromptType.Text).optional(),
prompt: z.string(),
config: jsonSchema.nullable().default({}),
@@ -22,7 +31,7 @@ export const CreateTextPromptSchema = z.object({
export const CreateChatPromptSchema = z.object({
name: z.string(),
isActive: z.boolean(),
labels: z.array(PromptLabelSchema).default([]),
type: z.literal(PromptType.Chat),
prompt: z.array(ChatMessageSchema),
config: jsonSchema.nullable().default({}),
@@ -49,11 +58,28 @@ export const CreatePromptTRPCSchema = z.union([
export type CreatePromptTRPCType = z.infer<typeof CreatePromptTRPCSchema>;
export const GetPromptsMetaSchema = z.object({
name: z.string().optional(),
version: z.coerce.number().int().nullish(),
label: z.string().optional(),
tag: z.string().optional(),
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(10),
});
export type GetPromptsMetaType = z.infer<typeof GetPromptsMetaSchema>;
export const GetPromptSchema = z.object({
name: z.string().transform((v) => decodeURIComponent(v)),
version: z.coerce.number().int().nullish(),
});
export const GetPromptByNameSchema = z.object({
promptName: z.string(),
version: z.coerce.number().int().nullish(),
label: z.string().optional(),
});
export const TextPromptSchema = z.object({
id: z.string(),
createdAt: z.date(),
@@ -62,7 +88,7 @@ export const TextPromptSchema = z.object({
createdBy: z.string(),
version: z.number(),
name: z.string(),
isActive: z.boolean(),
labels: z.array(PromptLabelSchema),
tags: z.array(z.string()),
type: z.literal(PromptType.Text),
prompt: z.string(),
@@ -83,7 +109,7 @@ export const ChatPromptSchema = z.object({
version: z.number(),
name: z.string(),
tags: z.array(z.string()),
isActive: z.boolean(),
labels: z.array(PromptLabelSchema),
type: z.literal(PromptType.Chat),
prompt: z.array(ChatMessageSchema),
config: jsonSchema,
@@ -96,3 +122,14 @@ export type ChatPromptType =
export const PromptSchema = z.union([TextPromptSchema, ChatPromptSchema]);
export type ValidatedPrompt = z.infer<typeof PromptSchema>;
// Backward compat for V1 prompts endpoint
export const LegacyCreatePromptSchema = z.union([
CreateTextPromptSchema.extend({ isActive: z.boolean() }),
CreateChatPromptSchema.extend({ isActive: z.boolean() }),
]);
export const LegacyPromptSchema = z.union([
TextPromptSchema.extend({ isActive: z.boolean() }),
ChatPromptSchema.extend({ isActive: z.boolean() }),
]);
export type LegacyValidatedPrompt = z.infer<typeof LegacyPromptSchema>;
+21 -7
View File
@@ -1,4 +1,4 @@
import { createPrompt } from "@/src/features/prompts/server/createPrompt";
import { createPrompt } from "@/src/features/prompts/server/actions/createPrompt";
import { verifyAuthHeaderAndReturnScope } from "@/src/features/public-api/server/apiAuth";
import { cors, runMiddleware } from "@/src/features/public-api/server/cors";
import { prisma } from "@langfuse/shared/src/db";
@@ -6,9 +6,9 @@ import { isPrismaException } from "@/src/utils/exceptions";
import { type NextApiRequest, type NextApiResponse } from "next";
import { z } from "zod";
import {
CreatePromptSchema,
LegacyCreatePromptSchema,
GetPromptSchema,
} from "@/src/features/prompts/server/validation";
} from "@/src/features/prompts/server/utils/validation";
import {
UnauthorizedError,
LangfuseNotFoundError,
@@ -16,6 +16,7 @@ import {
MethodNotAllowedError,
ForbiddenError,
} from "@langfuse/shared";
import { PRODUCTION_LABEL } from "@/src/features/prompts/constants";
export default async function handler(
req: NextApiRequest,
@@ -42,27 +43,40 @@ export default async function handler(
projectId: authCheck.scope.projectId,
name: searchParams.name,
version: searchParams.version ?? undefined, // if no version is given, we take the latest active prompt
isActive: !searchParams.version ? true : undefined, // if no prompt is active, there will be no prompt available
labels: !searchParams.version
? {
has: PRODUCTION_LABEL,
}
: undefined, // if no prompt is active, there will be no prompt available
},
});
if (!prompt) throw new LangfuseNotFoundError("Prompt not found");
return res.status(200).json(prompt);
return res.status(200).json({
...prompt,
isActive: prompt.labels.includes(PRODUCTION_LABEL),
});
}
// Handle POST requests
if (req.method === "POST") {
const input = CreatePromptSchema.parse(req.body);
const input = LegacyCreatePromptSchema.parse(req.body);
const prompt = await createPrompt({
...input,
labels: input.isActive
? [...new Set([...input.labels, PRODUCTION_LABEL])] // Ensure labels are unique
: input.labels, // If production label is already present, this will still promote the prompt
config: input.config ?? {}, // Config can be null in which case zod default value is not used
projectId: authCheck.scope.projectId,
createdBy: "API",
prisma: prisma,
});
return res.status(201).json(prompt);
return res.status(201).json({
...prompt,
isActive: prompt.labels.includes(PRODUCTION_LABEL),
});
}
throw new MethodNotAllowedError();
@@ -0,0 +1 @@
export { promptNameHandler as default } from "@/src/features/prompts/server/handlers/promptNameHandler";
@@ -0,0 +1 @@
export { promptsHandler as default } from "@/src/features/prompts/server/handlers/promptsHandler";
@@ -1,4 +1,4 @@
import { PromptType } from "@/src/features/prompts/server/validation";
import { PromptType } from "@/src/features/prompts/server/utils/validation";
import {
type ColumnDefinition,
type OptionsDefinition,
@@ -30,6 +30,13 @@ export const promptsTableCols: ColumnDefinition[] = [
internal: 'p."type"',
options: Object.values(PromptType).map((value) => ({ value })),
},
{
name: "Labels",
id: "labels",
type: "arrayOptions",
internal: 'p."labels"',
options: [], // to be added at runtime
},
{
name: "Tags",
id: "tags",
+1 -1
View File
@@ -12,7 +12,7 @@ import { environmentRouter } from "@/src/server/api/routers/environment";
import { usageMeteringRouter } from "@/src/features/usage-metering/server/usageMeteringRouter";
import { observationsRouter } from "@/src/server/api/routers/observations";
import { sessionRouter } from "@/src/server/api/routers/sessions";
import { promptRouter } from "@/src/features/prompts/server/prompt-router";
import { promptRouter } from "@/src/features/prompts/server/routers/promptRouter";
import { modelRouter } from "@/src/server/api/routers/models";
import { evalRouter } from "@/src/ee/features/evals/server/router";
import { posthogIntegrationRouter } from "@/src/features/posthog-integration/posthog-integration-router";
+12 -9
View File
@@ -3,6 +3,7 @@ import { type NextApiRequest } from "next";
import { type jsonSchema } from "@/src/utils/zod";
import lodash from "lodash";
import { env } from "@/src/env.mjs";
import { randomUUID } from "crypto";
// This function persists raw events to the database which came via API
// It relates each event to a project
@@ -27,13 +28,15 @@ export const persistEventMiddleware = async (
// combine metadata from the request and langfuseHeadersObject
const combinedMetadata = lodash.merge(metadata, langfuseHeadersObject);
await prisma.events.create({
data: {
project: { connect: { id: projectId } },
url: req.url,
method: req.method,
data: data,
headers: combinedMetadata,
},
});
await prisma.$queryRaw`
INSERT INTO events (id, project_id, url, method, data, headers)
VALUES (
${randomUUID()},
${projectId},
${req.url},
${req.method},
${data},
${combinedMetadata}
);
`;
};
+70
View File
@@ -0,0 +1,70 @@
import { isPrismaException } from "@/src/utils/exceptions";
import { cors, runMiddleware } from "@/src/features/public-api/server/cors";
import { type NextApiRequest, type NextApiResponse } from "next";
import { z } from "zod";
import { BaseError, MethodNotAllowedError } from "@langfuse/shared";
const httpMethods = ["GET", "POST", "PUT", "DELETE"] as const;
type HttpMethod = (typeof httpMethods)[number];
type Handlers = {
[Method in HttpMethod]?: (
req: NextApiRequest,
res: NextApiResponse,
) => Promise<void>;
};
const defaultHandler = () => {
throw new MethodNotAllowedError();
};
export function withMiddlewares(handlers: Handlers) {
return async (req: NextApiRequest, res: NextApiResponse) => {
try {
await runMiddleware(req, res, cors);
const method = req.method as HttpMethod;
if (!handlers[method]) throw new MethodNotAllowedError();
const finalHandlers: Required<Handlers> = {
...{
GET: defaultHandler,
POST: defaultHandler,
PUT: defaultHandler,
DELETE: defaultHandler,
},
...handlers,
};
return await finalHandlers[method](req, res);
} catch (error) {
console.error(error);
if (error instanceof BaseError) {
return res.status(error.httpCode).json({
message: error.message,
error: error.name,
});
}
if (isPrismaException(error)) {
return res.status(500).json({
message: "Internal Server Error",
error: "An unknown error occurred",
});
}
if (error instanceof z.ZodError) {
return res.status(400).json({
message: "Invalid request data",
error: error.errors,
});
}
return res.status(500).json({
message: "Internal Server Error",
error:
error instanceof Error ? error.message : "An unknown error occurred",
});
}
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "worker",
"version": "2.36.0",
"version": "2.37.2",
"description": "",
"license": "MIT",
"main": "index.js",
+31
View File
@@ -0,0 +1,31 @@
import { expect, test, describe } from "vitest";
import { createRedisEvents } from "../api";
describe.sequential("create redis events", () => {
test("deduplicate events from the same project id", async () => {
const events = [
{ traceId: "trace1", projectId: "project1" },
{ traceId: "trace2", projectId: "project1" },
{ traceId: "trace2", projectId: "project2" },
{ traceId: "trace3", projectId: "project2" },
{ traceId: "trace3", projectId: "project2" },
];
const jobs = createRedisEvents(events);
expect(jobs).toBeDefined();
expect(jobs.length).toBe(4);
const traceIdProjectIds = jobs.map((event) => ({
projectId: event.data.payload.projectId,
traceId: event.data.payload.traceId,
}));
expect(traceIdProjectIds).toEqual([
{ traceId: "trace1", projectId: "project1" },
{ traceId: "trace2", projectId: "project1" },
{ traceId: "trace2", projectId: "project2" },
{ traceId: "trace3", projectId: "project2" },
]);
});
});
+42 -21
View File
@@ -71,27 +71,9 @@ router
const events = eventBody.parse(body);
const jobs = events.map((event) => ({
name: QueueJobs.TraceUpsert,
data: {
payload: {
projectId: event.projectId,
traceId: event.traceId,
},
id: randomUUID(),
timestamp: new Date(),
name: QueueJobs.TraceUpsert as const,
},
opts: {
removeOnFail: 10_000,
removeOnComplete: true,
attempts: 5,
backoff: {
type: "exponential",
delay: 1000,
},
},
}));
// Find set of traces per project. There might be two events for the same trace in one API call.
// If we don't deduplicate, we will end up processing the same trace twice on two different workers in parallel.
const jobs = createRedisEvents(events);
await evalQueue?.addBulk(jobs); // add all jobs as bulk
@@ -103,3 +85,42 @@ router
router.use("/emojis", emojis);
export default router;
export function createRedisEvents(events: z.infer<typeof eventBody>) {
const uniqueTracesPerProject = events.reduce((acc, event) => {
if (!acc.get(event.projectId)) {
acc.set(event.projectId, new Set());
}
acc.get(event.projectId)?.add(event.traceId);
return acc;
}, new Map<string, Set<string>>());
const jobs = [...uniqueTracesPerProject.entries()]
.map((tracesPerProject) => {
const [projectId, traceIds] = tracesPerProject;
return [...traceIds].map((traceId) => ({
name: QueueJobs.TraceUpsert,
data: {
payload: {
projectId,
traceId,
},
id: randomUUID(),
timestamp: new Date(),
name: QueueJobs.TraceUpsert as const,
},
opts: {
removeOnFail: 10000,
removeOnComplete: true,
attempts: 5,
backoff: {
type: "exponential",
delay: 1000,
},
},
}));
})
.flat();
return jobs;
}
+2 -2
View File
@@ -30,9 +30,9 @@ if (isSentryEnabled) {
nodeProfilingIntegration(),
],
// Performance Monitoring
tracesSampleRate: 0.2, // Capture 100% of the transactions
tracesSampleRate: 0.1, // Capture 100% of the transactions
// Set sampling rate for profiling - this is relative to tracesSampleRate
profilesSampleRate: 0.2,
profilesSampleRate: 0.1,
});
// The request handler must be the first middleware on the app
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v2.36.0";
export const VERSION = "v2.37.2";
-3
View File
@@ -265,9 +265,6 @@ export const evaluate = async ({
.executeTakeFirst();
if (!apiKey) {
logger.error(
`API key for provider ${provider} and project ${event.projectId} not found. Failing job id ${job.id}`
);
// this will fail the eval execution if a user deletes the API key.
throw new LangfuseNotFoundError(
`API key for provider ${provider} and project ${event.projectId} not found.`