Compare commits

...
35 Commits
Author SHA1 Message Date
Nimar ebe85f2a67 chore: release v3.78.1 2025-07-02 12:15:50 +02:00
NimarandGitHub c7b62adbab feat(experiments) support for placeholders as message histories (#7615)
* run experiments with placeholders as well

* add missing file

* fix experiements

* run outside of loop

* add tests

* fix tests

* make tests clean again

* also test that variables resovle first
2025-07-02 10:04:51 +00:00
66d4926fd0 feat: add tags to search on prompts table (#7619)
* Enhance prompt search to include tags with case-insensitive matching

Co-authored-by: marc <marc@langfuse.com>

* drop md

* prettier

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-07-01 20:37:57 +00:00
b26f5c512f refactor: pivot table dropdowns (#7611)
---------

Co-authored-by: Max Deichmann <m.deichmann@tum.de>
2025-07-01 21:21:46 +02:00
marliessophieandGitHub 9227eaab9c feat(deleteButton): add custom delete message for running evaluator (#7617) 2025-07-01 18:33:41 +00:00
Nimar 3debac082a chore: release v3.78.0 2025-07-01 18:34:30 +02:00
fb43e03171 feat(prompts): Add search bar to prompts table (#7603)
* Add search functionality to prompts table with query param support

Co-authored-by: marc <marc@langfuse.com>

* prettier

* fix

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-07-01 14:16:45 +00:00
marliessophieandGitHub dd04da64ba fix(prisma): update JobExecution and DefaultLlmModel relations to include onUpdate and onDelete behaviors to match DB constraints (#7602)
* fix(prisma): update JobExecution and DefaultLlmModel relations to include onUpdate and onDelete behaviors to match DB constraints

* chore(prisma): update foreign key onUpdate behavior for job_template_id in JobExecution model

* Revert "chore(prisma): update foreign key onUpdate behavior for job_template_id in JobExecution model"

This reverts commit e65c12c9c36a1f8732560e665b1d8d7422c530f0.
2025-07-01 14:07:42 +00:00
b94cd9880a chore: fix typos (#7604)
* Fix typos: GitHub brand name and "check out" verb phrase

Co-authored-by: marc <marc@langfuse.com>

* Checkpoint before follow-up message

* Fix GitHub brand name and typos across documentation and UI

Co-authored-by: marc <marc@langfuse.com>

* Changes from background composer bc-bdbd909e-6932-4489-b125-e66b1d0f5ebe

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-07-01 13:54:09 +00:00
NimarandGitHub 0f2d2b8850 fix(prompts): don't allow placeholders and variable with same name on same prompt version (#7598)
* no naming collisions between prompt and placeholder

* tests for variable placeholder name collision

* fix test spelling

* also show conflict in playhground
2025-07-01 13:39:35 +00:00
steffen911 69db85cfe4 chore: release v3.77.0 2025-07-01 13:37:53 +02:00
Thorsten SpiekerandGitHub b445471f98 feat(dashboard): add pivot table widget type (#7546) 2025-07-01 13:36:54 +02:00
2ce014980b feat(posthog-export): Add environment attribute to PostHog exports (#7581)
* Add environment to PostHog event tracking for traces and observations

Co-authored-by: marc <marc@langfuse.com>

* use object envs instead of trace env

* fix

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-06-30 22:47:14 +00:00
Nimar 40ad6761cc chore: release v3.76.0 2025-06-30 19:13:49 +02:00
bd36669c6a feat(prompts): prompt chat message placeholders in backend and api (#7388)
* add first unit test

* first test passes

* share types

* fix tests

* update docs for placeholder

* improve tests

* add message compile

* update types

* add backend remaining

* fix playground

* review suggest

* add playground

* code review

* fix spelling

* fix validation in FE

* fix validation

* add seed data

* allow placeholdered prompt to playgrounf

* add tooltip

* fix build

* code feedback

* use zod to validate

* Update CLAUDE.md

* chore: remove prefix from user_id column (#7477)

* chore: add .env values for sdk integration tests (#7479)

* fix(data-tables): table peeks and full links should respect custom basePaths (#7483)

* strip url

* fix for dataset peek

* add to cursor rules

* chore: release v3.75.1

* feat: line breaks on evaluation run tooltip hints (#7486)

* Fix comment display to preserve whitespace and line breaks

* include in seeder

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>

* chore: reduce number of listed S3 files in event processing (#7487)

* chore(cloud): apply "cloud:demo" tier to demo org tenant in plain sup… (#7489)

chore(cloud): apply "cloud:demo" tier to demo org tenant in plain support

* make the api beautiful again

* update api

* reorg import

* fix improt

* prefill message placeholders

* remove todos

* fix prompt mapping

---------

Co-authored-by: Steffen Schmitz <steffen@langfuse.com>
Co-authored-by: Marc Klingen <git@marcklingen.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-06-30 16:54:07 +00:00
Steffen SchmitzandGitHub 98cc1bb7a1 exp: add aggregating merge trees experiment for traces tables (#7574)
* exp: add aggregating merge trees experiment for traces tables

* chore: lint
2025-06-30 16:46:50 +00:00
marliessophieandGitHub 3dcdbd99b3 fix(evals): show loading state of detail buttons only if preview is enabled (#7570)
fix(evals): show loading state of detail buttons only if show preview is enabled
2025-06-30 14:40:17 +00:00
Hassieb PakzadandGitHub 1a5c5f6383 fix(otel): skip multiproject check for non-python (#7568) 2025-06-30 13:38:14 +00:00
Steffen SchmitzandGitHub 867f6c7484 feat(dashboard): allow filtering by score value in custom dashboards (#7567)
* feat(dashboard): allow filtering by score value in custom dashboards

* chore: remove console log
2025-06-30 13:16:14 +00:00
NimarandGitHub 1097f1b8d5 chore(dx): show different favicon for dev mode - easier to find tab (#7550)
add dev favicon
2025-06-30 12:00:25 +00:00
Marc KlingenandGitHub a73156bbe5 fix: warn if missing encryption_key when updating posthog integration (#7560)
* fix: warn if missing encryption_key when updating posthog integration

* push
2025-06-30 11:20:22 +00:00
marliessophieandGitHub 7a9c62b106 refactor(evals): optimize variable extraction logic in EvaluationPromptPreview and useExtractVariables hooks (#7409)
* refactor(evals): optimize variable extraction logic in EvaluationPromptPreview and useExtractVariables hooks

* style: improve preview header

* chore: eslint
2025-06-30 10:57:37 +00:00
marliessophieandGitHub 70cc910ee9 fix(datasets): allow users to update metadata and description while keeping name constant (#7562)
* feat: add whitelisted name support for unique name validation in dataset form

* fix: pass metadata to update dataset form
2025-06-30 09:55:00 +00:00
Steffen SchmitzandGitHub 64a71f059f chore: add trace update compatibility properties (#7561) 2025-06-30 09:28:23 +00:00
Max DeichmannandGitHub f5a0c7cfed chore: move prompt types into shared package (#7555)
* push

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes
2025-06-29 10:38:14 +00:00
Marc KlingenandGitHub 2472d2f6da chore(cloud): remove posthog groups to reduce costs (#7552) 2025-06-27 20:01:38 +00:00
Max DeichmannandGitHub dd0446f4dd chore: add python sdk v3 notification (#7543)
* fixes

* push
2025-06-27 14:08:47 +00:00
Max DeichmannandGitHub 7263a1554c chore: fix admin api (#7530)
* fixes

* fixes
2025-06-27 11:10:00 +00:00
Max DeichmannandGitHub 2c25c27b51 chore: skip s3 LIST for projects configured in env (#7541)
push
2025-06-27 11:09:50 +00:00
Steffen SchmitzandGitHub f2b92e1f23 chore: only consider getWaitingCount for queue length (#7542) 2025-06-27 09:44:37 +00:00
Steffen SchmitzandGitHub 593c0a567c perf: execute eval variable parsing sequentially (#7540)
* perf: execute eval variable parsing sequentially

* chore: skip caching for dataset items
2025-06-27 09:24:02 +00:00
Steffen SchmitzandGitHub ac1f465634 perf: cache eval entities during variable extraction (#7538) 2025-06-27 07:56:01 +00:00
NimarandGitHub 083b1357a1 fix: long trace names shouldn't break at icons (#7382)
* fix breaks

* fix

* undo
2025-06-26 18:27:36 +00:00
Nimar 30e594e839 chore: release v3.75.4 2025-06-26 19:25:59 +02:00
NimarGitHubellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2b8f583c44 fix(prompts): custom label creation popover too wide (#7529)
* fix UI

* disable button if nothing changed

* Update web/src/features/prompts/components/SetPromptVersionLabels/index.tsx

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-06-26 17:20:27 +00:00
138 changed files with 6407 additions and 818 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ alwaysApply: true
## File Structure
- We generally put all code related to a net-new feature into a folder within web/src/features.
- Checkout other features to learn about the common structure.
- Check out other features to learn about the common structure.
## API for frontend features
+4 -1
View File
@@ -182,4 +182,7 @@ To get a project, use the `get_project` capability with the full project name as
## Front-end Tips
### Window Location Handling
- Whenever you want to use or do use window.location..., ensure that you also add proper handling for a custom basePath
- Whenever you want to use or do use window.location..., ensure that you also add proper handling for a custom basePath
## TypeScript Best Practices
- In TypeScript, if possible, don't use the `any` type
+1 -1
View File
@@ -26,7 +26,7 @@
<a href="https://langfuse.com/roadmap"><strong>路线图</strong></a> ·
</div>
<br/>
<span>Langfuse 使用 <a href="https://github.com/orgs/langfuse/discussions"><strong>Github Discussions</strong></a> 作为支持和功能请求的平台。</span>
<span>Langfuse 使用 <a href="https://github.com/orgs/langfuse/discussions"><strong>GitHub Discussions</strong></a> 作为支持和功能请求的平台。</span>
<br/>
<span><b>我们正在招聘。</b> <a href="https://langfuse.com/careers"><strong>加入我们</strong></a>,从事产品工程和技术市场职位。</span>
<br/>
+1 -1
View File
@@ -26,7 +26,7 @@
<a href="https://langfuse.com/roadmap"><strong>ロードマップ</strong></a> ·
</div>
<br/>
<span>Langfuseは、サポートと機能リクエストのために <a href="https://github.com/orgs/langfuse/discussions"><strong>Github Discussions</strong></a> を利用しています。</span>
<span>Langfuseは、サポートと機能リクエストのために <a href="https://github.com/orgs/langfuse/discussions"><strong>GitHub Discussions</strong></a> を利用しています。</span>
<br/>
<span><b>We're hiring.</b> <a href="https://langfuse.com/careers"><strong>チームに加わる</strong></a> (製品エンジニアリングおよびテクニカルGTMのポジション)への応募をお待ちしています。</span>
<br/>
+1 -1
View File
@@ -26,7 +26,7 @@
<a href="https://langfuse.com/roadmap"><strong>Roadmap</strong></a> ·
</div>
<br/>
<span>Langfuse uses <a href="https://github.com/orgs/langfuse/discussions"><strong>Github Discussions</strong></a> for Support and Feature Requests.</span>
<span>Langfuse uses <a href="https://github.com/orgs/langfuse/discussions"><strong>GitHub Discussions</strong></a> for Support and Feature Requests.</span>
<br/>
<span><b>We're hiring.</b> <a href="https://langfuse.com/careers"><strong>Join us</strong></a> in product engineering and technical go-to-market roles.</span>
<br/>
+12 -2
View File
@@ -82,7 +82,7 @@ types:
CreateChatPromptRequest:
properties:
name: string
prompt: list<ChatMessage>
prompt: list<ChatMessageWithPlaceholders>
config: optional<unknown>
labels:
type: optional<list<string>>
@@ -132,6 +132,11 @@ types:
type: optional<map<string, unknown>>
docs: The dependency resolution graph for the current prompt. Null if prompt has no dependencies.
ChatMessageWithPlaceholders:
union:
chatmessage: ChatMessage
placeholder: PlaceholderMessage
ChatMessage:
properties:
role:
@@ -139,6 +144,11 @@ types:
content:
type: string
PlaceholderMessage:
properties:
name:
type: string
TextPrompt:
extends: BasePrompt
properties:
@@ -147,4 +157,4 @@ types:
ChatPrompt:
extends: BasePrompt
properties:
prompt: list<ChatMessage>
prompt: list<ChatMessageWithPlaceholders>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "3.75.3",
"version": "3.78.1",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
+2 -1
View File
@@ -111,7 +111,8 @@ export const DashboardWidgetChartType = {
VERTICAL_BAR: "VERTICAL_BAR",
PIE: "PIE",
NUMBER: "NUMBER",
HISTOGRAM: "HISTOGRAM"
HISTOGRAM: "HISTOGRAM",
PIVOT_TABLE: "PIVOT_TABLE"
} as const;
export type DashboardWidgetChartType = (typeof DashboardWidgetChartType)[keyof typeof DashboardWidgetChartType];
export type Account = {
@@ -0,0 +1,9 @@
-- Database migration to add PIVOT_TABLE chart type to DashboardWidgetChartType enum
-- This enables the creation of pivot table widgets in the dashboard system
--
-- This migration adds support for tabular data visualization with configurable
-- row dimensions and metrics, extending the existing widget types (line charts,
-- bar charts, pie charts, etc.) to include pivot table functionality.
-- AlterEnum
ALTER TYPE "DashboardWidgetChartType" ADD VALUE 'PIVOT_TABLE';
+3 -2
View File
@@ -871,7 +871,7 @@ model JobExecution {
jobConfiguration JobConfiguration @relation(fields: [jobConfigurationId], references: [id], onDelete: Cascade)
jobTemplateId String? @map("job_template_id")
jobTemplate EvalTemplate? @relation(fields: [jobTemplateId], references: [id], onDelete: SetNull)
jobTemplate EvalTemplate? @relation(fields: [jobTemplateId], references: [id], onDelete: SetNull, onUpdate: NoAction)
status JobExecutionStatus
startTime DateTime? @map("start_time")
@@ -902,7 +902,7 @@ model DefaultLlmModel {
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
projectId String @map("project_id")
Project Project @relation(fields: [projectId], references: [id])
Project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
llmApiKeyId String @map("llm_api_key_id")
LlmApiKey LlmApiKeys @relation("LlmApiKeyId", fields: [llmApiKeyId], references: [id], onDelete: Cascade)
@@ -1157,6 +1157,7 @@ enum DashboardWidgetChartType {
PIE
NUMBER
HISTOGRAM
PIVOT_TABLE
}
model DashboardWidget {
+11 -12
View File
@@ -1,18 +1,18 @@
import {
PrismaClient,
type Project,
ScoreDataType,
JobConfiguration,
JobExecutionStatus,
} from "../../src/index";
import { hash } from "bcryptjs";
import { randomUUID } from "node:crypto";
import { parseArgs } from "node:util";
import { hash } from "bcryptjs";
import { v4 } from "uuid";
import { getDisplaySecretKey, hashSecretKey, logger } from "../../src/server";
import { encrypt } from "../../src/encryption";
import { redis } from "../../src/server/redis/redis";
import { randomUUID } from "crypto";
import {
type JobConfiguration,
JobExecutionStatus,
PrismaClient,
type Project,
ScoreDataType,
} from "../../src/index";
import { getDisplaySecretKey, hashSecretKey, logger } from "../../src/server";
import { redis } from "../../src/server/redis/redis";
import {EVAL_TRACE_COUNT,
FAILED_EVAL_TRACE_INTERVAL,
SEED_CHAT_ML_PROMPTS,
SEED_DATASETS,
@@ -27,7 +27,6 @@ import {
generateEvalScoreId,
generateEvalTraceId,
} from "./utils/seed-helpers";
import { EVAL_TRACE_COUNT } from "./utils/postgres-seed-constants";
type ConfigCategory = {
label: string;
@@ -307,6 +307,34 @@ export const SEED_CHAT_ML_PROMPTS = [
labels: ["production", "latest"],
tags: ["tag1", "tag2"],
},
{
id: `prompt-chat-placeholder`,
createdBy: "user-1",
prompt: [
{
role: "system",
content:
'You are a very enthusiastic Langfuse representative who loves to help people! Langfuse is an open-source observability tool for developers of applications that use Large Language Models (LLMs). Given the following sections from the Langfuse documentation, answer the question using only that information, outputted in markdown format.\n\nPlease follow these guidelines:\n- Refer to the respective links of the documentation and select quality examples\n- Be kind.\n- Include emojis where it makes sense.\n- If the users have problems using Langfuse, tell them to reach out to the founders directly via the chat widget or GitHub at the end of your answer.\n- Answer as markdown, use highlights and paragraphs to structure the text.\n- Do not mention that you are "enthusiastic", the user does not need to know, will feel it from the style of your answers.\n- Only use information that is available in the context, do not make up any code that is not in the context.\n- Always put an empji at the end of the message.',
},
{
type: "placeholder",
name: "message_history",
},
{
role: "assistant",
content:
"Answering in next message based on your instructions only. What is the question?",
},
{
role: "user",
content: "{{question}}",
},
],
name: "prompt-chat-ml-with-placeholder",
version: 1,
labels: ["production", "latest"],
tags: ["tag1", "tag2"],
},
];
export const SEED_PROMPT_VERSIONS = [
+1
View File
@@ -105,6 +105,7 @@ const EnvSchema = z.object({
.number()
.default(80e6), // 80MB
LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS: z.coerce.number().default(240_000), // 4 minutes
LANGFUSE_SKIP_S3_LIST_FOR_OBSERVATIONS_PROJECT_IDS: z.string().optional(),
});
export const env: z.infer<typeof EnvSchema> =
@@ -1,15 +1,17 @@
import { z } from "zod/v4";
import { jsonSchema, PromptNameSchema } from "@langfuse/shared";
import type { Prompt } from "@langfuse/shared";
import { COMMIT_MESSAGE_MAX_LENGTH } from "@/src/features/prompts/constants";
import type { Prompt } from "../../../prisma/generated/types";
import { jsonSchema } from "../../utils/zod";
import { COMMIT_MESSAGE_MAX_LENGTH } from "./constants";
import { PromptChatMessageSchema } from "../../server/llm/types";
import { PromptNameSchema } from "./validation";
export const ChatMessageSchema = z.object({
role: z.string(),
content: z.string(),
});
export const SingleChatMessageSchema = PromptChatMessageSchema;
export type SingleChatMessage = z.infer<typeof SingleChatMessageSchema>;
export enum PromptType {
// eslint-disable-next-line no-unused-vars
Chat = "chat",
// eslint-disable-next-line no-unused-vars
Text = "text",
}
@@ -41,7 +43,7 @@ const BaseCreateChatPromptSchema = z.object({
name: PromptNameSchema,
labels: z.array(PromptLabelSchema).default([]),
type: z.literal(PromptType.Chat),
prompt: z.array(ChatMessageSchema),
prompt: z.array(PromptChatMessageSchema),
config: jsonSchema.nullable().default({}),
tags: z.array(z.string()).nullish(),
});
@@ -134,7 +136,7 @@ export const BaseChatPromptSchema = z.object({
tags: z.array(z.string()),
labels: z.array(PromptLabelSchema),
type: z.literal(PromptType.Chat),
prompt: z.array(ChatMessageSchema),
prompt: z.array(PromptChatMessageSchema),
config: jsonSchema,
});
+4
View File
@@ -13,6 +13,7 @@ export * from "./utils/json";
export * from "./utils/stringChecks";
export * from "./utils/objects";
export * from "./utils/typeChecks";
export * from "./utils/prompts";
export * from "./features/entitlements/plans";
export * from "./interfaces/rate-limits";
export * from "./tableDefinitions/typeHelpers";
@@ -43,6 +44,9 @@ export * from "./features/experiments/utils";
// prompts
export * from "./features/prompts/parsePromptDependencyTags";
export * from "./features/prompts/validation";
export * from "./features/prompts/types";
export * from "./features/prompts/constants";
export { compileChatMessages, compileChatMessagesWithIds, isPlaceholder, type MessagePlaceholderValues, type PromptMessage as ServerPromptMessage } from "./server/llm/compileChatMessages";
// export db types only
export * from "@prisma/client";
+1
View File
@@ -11,6 +11,7 @@ export * from "./auth/gitHubEnterpriseProvider";
export * from "./llm/fetchLLMCompletion";
export * from "./llm/utils";
export * from "./llm/types";
export * from "./llm/compileChatMessages";
export * from "./utils/DatabaseReadStream";
export * from "./utils/transforms";
export * from "./clickhouse/client";
@@ -229,12 +229,21 @@ export const processEventBatch = async (
throw new Error("Redis not initialized, aborting event processing");
}
const projectIdsToSkipS3List =
env.LANGFUSE_SKIP_S3_LIST_FOR_OBSERVATIONS_PROJECT_IDS?.split(",") ?? [];
await Promise.all(
Object.keys(sortedBatchByEventBodyId).map(async (id) => {
const eventData = sortedBatchByEventBodyId[id];
const shardingKey = `${authCheck.scope.projectId}-${eventData.eventBodyId}`;
const queue = IngestionQueue.getInstance({ shardingKey });
const shouldSkipS3List =
getClickhouseEntityType(eventData.type) === "observation" &&
authCheck.scope.projectId !== null &&
(projectIdsToSkipS3List.includes(authCheck.scope.projectId) ||
source === "otel");
return queue
? queue.add(
QueueJobs.IngestionJob,
@@ -247,9 +256,7 @@ export const processEventBatch = async (
type: eventData.type,
eventBodyId: eventData.eventBodyId,
fileKey: eventData.key,
skipS3List:
source === "otel" &&
getClickhouseEntityType(eventData.type) === "observation",
skipS3List: shouldSkipS3List,
},
authCheck: authCheck as {
validKey: true;
@@ -0,0 +1,116 @@
import { z } from "zod/v4";
import { v4 as uuidv4 } from "uuid";
import { type ChatMessage, type PlaceholderMessage, ChatMessageType, type PromptChatMessageSchema, type ChatMessageWithId, type ChatMessageWithIdNoPlaceholders, ChatMessageSchema } from "./types";
export type MessagePlaceholderValues = Record<string, ChatMessage[]>;
export type PromptMessage = z.infer<typeof PromptChatMessageSchema>;
export function isPlaceholder(message: PromptMessage): message is PlaceholderMessage {
return "type" in message && message.type === ChatMessageType.Placeholder;
}
function validateMessage(message: unknown): message is ChatMessage {
return ChatMessageSchema.safeParse(message).success;
}
function replaceTextVariables(
content: string,
textVariables: Record<string, string>
): string {
let result = content;
for (const [varName, varValue] of Object.entries(textVariables)) {
// Create regex that handles optional whitespace around variable name
const variablePattern = new RegExp(`{{\\s*${varName}\\s*}}`, "g");
result = result.replace(variablePattern, varValue);
}
return result;
}
function expandPlaceholder(
placeholder: PlaceholderMessage,
placeholderValues: MessagePlaceholderValues
): ChatMessage[] {
const replacementMessages = placeholderValues[placeholder.name];
if (!replacementMessages) {
throw new Error(`Missing value for message placeholder: ${placeholder.name}`);
}
if (!Array.isArray(replacementMessages)) {
throw new Error(`Placeholder value for '${placeholder.name}' must be an array of messages`);
}
for (const replacementMsg of replacementMessages) {
if (!validateMessage(replacementMsg)) {
throw new Error(`Invalid message format in placeholder '${placeholder.name}': messages must have 'role' and 'content' properties`);
}
}
return replacementMessages;
}
export function compileChatMessages(
messages: PromptMessage[],
placeholderValues: MessagePlaceholderValues,
textVariables?: Record<string, string>
): ChatMessage[] {
const expandedMessages = messages.flatMap((message) =>
isPlaceholder(message)
? expandPlaceholder(message, placeholderValues)
: [message as ChatMessage]
);
// substitute text variables
if (!textVariables || Object.keys(textVariables).length === 0) {
return expandedMessages;
}
return expandedMessages.map((message) => {
if (!message.content) {
return message;
}
return {
...message,
content: replaceTextVariables(message.content, textVariables)
};
});
}
export function compileChatMessagesWithIds(
messages: ChatMessageWithId[],
placeholderValues: Record<string, ChatMessage[]>,
textVariables?: Record<string, string>
): ChatMessageWithIdNoPlaceholders[] {
// TODO: check, is it even important to retain the IDs?
const expandedMessages = messages.flatMap((message) => {
if (isPlaceholder(message)) {
const expandedMsgs = expandPlaceholder(message, placeholderValues);
return expandedMsgs.map(msg => ({ ...msg, id: uuidv4() }));
} else {
// Preserve message IDs for already non-placeholder messages
return [message as ChatMessageWithIdNoPlaceholders];
}
});
// substitute text variables
if (!textVariables || Object.keys(textVariables).length === 0) {
return expandedMessages;
}
return expandedMessages.map((message) => {
if (!message.content) {
return message;
}
return {
...message,
content: replaceTextVariables(message.content, textVariables)
};
});
}
export function extractPlaceholderNames(messages: PromptMessage[]): string[] {
return messages
.filter((msg): msg is PlaceholderMessage => "type" in msg && msg.type === ChatMessageType.Placeholder)
.map(msg => msg.name);
}
+20 -7
View File
@@ -115,6 +115,8 @@ export enum ChatMessageRole {
Tool = "tool",
}
// Thought: should placeholder not semantically be part of this, because it can be
// PublicAPICreated of type? Works for now though.
export enum ChatMessageType {
System = "system",
Developer = "developer",
@@ -123,6 +125,7 @@ export enum ChatMessageType {
AssistantToolCall = "assistant-tool-call",
ToolResult = "tool-result",
PublicAPICreated = "public-api-created",
Placeholder = "placeholder",
}
export const SystemMessageSchema = z.object({
@@ -171,6 +174,12 @@ export const ToolResultMessageSchema = z.object({
});
export type ToolResultMessage = z.infer<typeof ToolResultMessageSchema>;
export const PlaceholderMessageSchema = z.object({
type: z.literal(ChatMessageType.Placeholder),
name: z.string().regex(/^[a-zA-Z][a-zA-Z0-9_]*$/, "Placeholder name must start with a letter and contain only alphanumeric characters and underscores"),
});
export type PlaceholderMessage = z.infer<typeof PlaceholderMessageSchema>;
export const ChatMessageDefaultRoleSchema = z.enum(ChatMessageRole);
export const ChatMessageSchema = z.union([
SystemMessageSchema,
@@ -193,12 +202,16 @@ export const ChatMessageSchema = z.union([
]);
export type ChatMessage = z.infer<typeof ChatMessageSchema>;
export type ChatMessageWithId = ChatMessage & { id: string };
export type ChatMessageWithId = (ChatMessage & { id: string }) | (PlaceholderMessage & { id: string });
export type ChatMessageWithIdNoPlaceholders = (ChatMessage & { id: string });
export const PromptChatMessageSchema = z.object({
role: z.string(),
content: z.string(),
});
export const PromptChatMessageSchema = z.union([
z.object({
role: z.string(),
content: z.string(),
}),
PlaceholderMessageSchema,
]);
export const PromptChatMessageListSchema = z.array(PromptChatMessageSchema);
export type PromptVariable = { name: string; value: string; isUsed: boolean };
@@ -218,11 +231,11 @@ export const SYSTEM_ROLES: string[] = [
ChatMessageRole.Developer,
];
export const TextPromptSchema = z.string().min(1, "Enter a prompt");
export const TextPromptContentSchema = z.string().min(1, "Enter a prompt");
export const PromptContentSchema = z.union([
PromptChatMessageListSchema,
TextPromptSchema,
TextPromptContentSchema,
]);
export type PromptContent = z.infer<typeof PromptContentSchema>;
@@ -122,6 +122,43 @@ export const traceRecordInsertSchema = traceRecordBaseSchema.extend({
});
export type TraceRecordInsertType = z.infer<typeof traceRecordInsertSchema>;
export const traceMtRecordInsertSchema = z.object({
// Identifiers
project_id: z.string(),
id: z.string(),
start_time: z.number(),
end_time: z.number().nullish(),
name: z.string(),
// Metadata properties
metadata: z.record(z.string(), z.string()),
user_id: z.string(),
session_id: z.string(),
environment: z.string(),
tags: z.array(z.string()),
version: z.string().nullish(),
release: z.string().nullish(),
// UI properties - nullable to prevent absent values being interpreted as overwrites
bookmarked: z.boolean().nullish(),
public: z.boolean().nullish(),
// Aggregations
observation_ids: z.array(z.string()),
score_ids: z.array(z.string()),
cost_details: z.record(z.string(), z.number()),
usage_details: z.record(z.string(), z.number()),
// Input/Output
input: z.string(),
output: z.string(),
created_at: z.number(),
updated_at: z.number(),
event_ts: z.number(),
});
export type TraceMtRecordInsertType = z.infer<typeof traceMtRecordInsertSchema>;
export const scoreRecordBaseSchema = z.object({
id: z.string(),
project_id: z.string(),
@@ -687,7 +687,11 @@ const getObservationsTableInternal = async <T>(
const appliedScoresFilter = scoresFilter.apply();
const appliedObservationsFilter = observationsFilter.apply();
const search = clickhouseSearchCondition(opts.searchQuery, opts.searchType, "o");
const search = clickhouseSearchCondition(
opts.searchQuery,
opts.searchType,
"o",
);
const scoresCte = `WITH scores_agg AS (
SELECT
@@ -1494,6 +1498,7 @@ export const getGenerationsForPostHog = async function* (
o.provided_model_name as model,
o.level as level,
o.version as version,
o.environment as environment,
t.id as trace_id,
t.name as trace_name,
t.session_id as trace_session_id,
@@ -1556,6 +1561,7 @@ export const getGenerationsForPostHog = async function* (
langfuse_model: record.model,
langfuse_level: record.level,
langfuse_tags: record.trace_tags,
langfuse_environment: record.environment,
langfuse_event_version: "1.0.0",
$session_id: record.posthog_session_id ?? null,
$set: {
@@ -1336,6 +1336,7 @@ export const getScoresForPostHog = async function* (
s.name as name,
s.value as value,
s.comment as comment,
s.environment as environment,
t.name as trace_name,
t.session_id as trace_session_id,
t.user_id as trace_user_id,
@@ -1388,6 +1389,7 @@ export const getScoresForPostHog = async function* (
langfuse_user_id: record.trace_user_id || "langfuse_unknown_user",
langfuse_release: record.trace_release,
langfuse_tags: record.trace_tags,
langfuse_environment: record.environment,
langfuse_event_version: "1.0.0",
$session_id: record.posthog_session_id ?? null,
$set: {
@@ -957,6 +957,7 @@ export const getTracesForPostHog = async function* (
t.release as release,
t.version as version,
t.tags as tags,
t.environment as environment,
t.metadata['$posthog_session_id'] as posthog_session_id,
o.total_cost as total_cost,
o.latency_milliseconds / 1000 as latency,
@@ -1006,6 +1007,7 @@ export const getTracesForPostHog = async function* (
langfuse_release: record.release,
langfuse_version: record.version,
langfuse_tags: record.tags,
langfuse_environment: record.environment,
langfuse_event_version: "1.0.0",
$session_id: record.posthog_session_id ?? null,
$set: {
@@ -33,6 +33,10 @@ export const HistogramChartConfig = BaseTotalValueChartConfig.extend({
bins: z.number().int().min(1).max(100).optional().default(10),
});
export const PivotTableChartConfig = BaseTotalValueChartConfig.extend({
type: z.literal("PIVOT_TABLE"),
});
// Define dimension schema
export const DimensionSchema = z.object({
field: z.string(),
@@ -53,6 +57,7 @@ export const ChartConfigSchema = z.discriminatedUnion("type", [
PieChartConfig,
BigNumberChartConfig,
HistogramChartConfig,
PivotTableChartConfig,
]);
export const DashboardDefinitionWidgetWidgetSchema = z.object({
@@ -21,9 +21,12 @@ export class PromptService {
private ttlSeconds: number;
constructor(
// eslint-disable-next-line no-unused-vars
private prisma: PrismaClient,
private redis: Redis | Cluster | null,
// eslint-disable-next-line no-unused-vars
private metricIncrementer?: // used for otel metrics
// eslint-disable-next-line no-unused-vars
(name: string, value?: number) => void,
cacheEnabled?: boolean, // used for testing
) {
+24
View File
@@ -0,0 +1,24 @@
/**
* Client-safe utility functions for prompt handling
*/
export interface PromptMessage {
type?: string;
name?: string;
role?: string;
content?: string;
}
/**
* Extracts placeholder names from prompt messages.
* This is a client-safe version that doesn't depend on server-side types.
* @param messages Array of prompt messages
* @returns Array of placeholder names
*/
export function extractPlaceholderNames(messages: PromptMessage[]): string[] {
return messages
.filter((msg): msg is PromptMessage & { name: string } =>
msg.type === "placeholder" && typeof msg.name === "string"
)
.map(msg => msg.name);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "3.75.3",
"version": "3.78.1",
"private": true,
"license": "MIT",
"engines": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 779 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

+35 -2
View File
@@ -6740,7 +6740,7 @@ components:
prompt:
type: array
items:
$ref: '#/components/schemas/ChatMessage'
$ref: '#/components/schemas/ChatMessageWithPlaceholders'
config:
nullable: true
labels:
@@ -6854,6 +6854,31 @@ components:
- config
- labels
- tags
ChatMessageWithPlaceholders:
title: ChatMessageWithPlaceholders
oneOf:
- type: object
allOf:
- type: object
properties:
type:
type: string
enum:
- chatmessage
- $ref: '#/components/schemas/ChatMessage'
required:
- type
- type: object
allOf:
- type: object
properties:
type:
type: string
enum:
- placeholder
- $ref: '#/components/schemas/PlaceholderMessage'
required:
- type
ChatMessage:
title: ChatMessage
type: object
@@ -6865,6 +6890,14 @@ components:
required:
- role
- content
PlaceholderMessage:
title: PlaceholderMessage
type: object
properties:
name:
type: string
required:
- name
TextPrompt:
title: TextPrompt
type: object
@@ -6882,7 +6915,7 @@ components:
prompt:
type: array
items:
$ref: '#/components/schemas/ChatMessage'
$ref: '#/components/schemas/ChatMessageWithPlaceholders'
required:
- prompt
allOf:
+1 -1
View File
@@ -2151,7 +2151,7 @@
"auth": null,
"body": {
"mode": "raw",
"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 \"tags\": [\n \"example\"\n ],\n \"commitMessage\": \"example\"\n}",
"raw": "{\n \"type\": \"chat\",\n \"name\": \"example\",\n \"prompt\": [\n {\n \"type\": \"chatmessage\",\n \"role\": \"example\",\n \"content\": \"example\"\n }\n ],\n \"config\": \"UNKNOWN\",\n \"labels\": [\n \"example\"\n ],\n \"tags\": [\n \"example\"\n ],\n \"commitMessage\": \"example\"\n}",
"options": {
"raw": {
"language": "json"
@@ -0,0 +1,152 @@
/** @jest-environment node */
import { ChatMessageType, compileChatMessages, extractPlaceholderNames } from "@langfuse/shared";
describe("compileChatMessages", () => {
it("should compile message placeholders with provided values", () => {
// Simulates how message placeholders would be compiled
// during execution (e.g., in playground or experiments)
const promptTemplate = [
{ role: "system", content: "You are a helpful assistant." },
{
type: ChatMessageType.Placeholder,
name: "conversation_history"
},
{ role: "user", content: "{{user_question}}" }
];
const placeholderValues = {
conversation_history: [
{ role: "user", content: "Hello!" },
{ role: "assistant", content: "Hi there! How can I help you?" },
{ role: "user", content: "What's the weather like?" }
]
};
const textVariables = {
user_question: "Can you continue our conversation?"
};
// Simulate compilation logic that would happen in playground/experiments
const compiledMessages = compileChatMessages(
promptTemplate,
placeholderValues,
textVariables
);
expect(compiledMessages).toEqual([
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "Hello!" },
{ role: "assistant", content: "Hi there! How can I help you?" },
{ role: "user", content: "What's the weather like?" },
{ role: "user", content: "Can you continue our conversation?" }
]);
});
it("should throw error when placeholder value is missing", () => {
const promptTemplate = [
{ role: "system", content: "You are a helpful assistant." },
{
type: ChatMessageType.Placeholder,
name: "missing_placeholder"
},
{ role: "user", content: "Hello" }
];
const placeholderValues = {};
expect(() => {
compileChatMessages(promptTemplate, placeholderValues);
}).toThrow("Missing value for message placeholder: missing_placeholder");
});
it("should throw error when placeholder messages lack required properties", () => {
const promptTemplate = [
{
type: ChatMessageType.Placeholder,
name: "invalid_messages"
}
];
// Test missing role property
const placeholderValuesNoRole = {
invalid_messages: [
{ content: "Hello" }
]
};
expect(() => {
compileChatMessages(promptTemplate, placeholderValuesNoRole);
}).toThrow("Invalid message format in placeholder 'invalid_messages': messages must have 'role' and 'content' properties");
// Test missing content property
const placeholderValuesNoContent = {
invalid_messages: [
{ role: "user" }
]
};
expect(() => {
compileChatMessages(promptTemplate, placeholderValuesNoContent);
}).toThrow("Invalid message format in placeholder 'invalid_messages': messages must have 'role' and 'content' properties");
});
it("should compile placeholders without applying text substitutions when no variables provided", () => {
const promptTemplate = [
{ role: "system", content: "You are a helpful assistant. {{system_var}}" },
{
type: ChatMessageType.Placeholder,
name: "history"
},
{ role: "user", content: "{{user_var}}" }
];
const placeholderValues = {
history: [
{ role: "user", content: "Previous message with {{var}}" },
{ role: "assistant", content: "Response with {{another_var}}" }
]
};
// No text variables provided
const compiledMessages = compileChatMessages(promptTemplate, placeholderValues);
expect(compiledMessages).toEqual([
{ role: "system", content: "You are a helpful assistant. {{system_var}}" },
{ role: "user", content: "Previous message with {{var}}" },
{ role: "assistant", content: "Response with {{another_var}}" },
{ role: "user", content: "{{user_var}}" }
]);
});
it("should extract all placeholder names from messages", () => {
const promptTemplate = [
{ role: "system", content: "System message" },
{
type: ChatMessageType.Placeholder,
name: "history"
},
{ role: "user", content: "User message" },
{
type: ChatMessageType.Placeholder,
name: "context"
}
];
const placeholderNames = extractPlaceholderNames(promptTemplate);
expect(placeholderNames).toEqual(["history", "context"]);
});
it("should return empty array when no placeholders exist", () => {
const promptTemplate = [
{ role: "system", content: "System message" },
{ role: "user", content: "User message" }
];
const placeholderNames = extractPlaceholderNames(promptTemplate);
expect(placeholderNames).toEqual([]);
});
});
@@ -0,0 +1,679 @@
/**
* @fileoverview Integration tests for Pivot Table widget functionality in dashboard router
*
* This test suite validates the complete data pipeline for pivot table widgets:
* - Query generation and execution through executeQuery function
* - SQL generation by QueryBuilder for various pivot table configurations
* - Data transformation from raw query results to pivot table structure
* - Integration with ClickHouse database and error handling
*
* Test Coverage:
* - Zero dimension pivot tables (grand total only)
* - Single dimension pivot tables with subtotals
* - Two dimension pivot tables with nested structure
* - Row limiting functionality
* - Error handling for malformed queries
* - Integration with existing dashboard query infrastructure
*/
import { randomUUID } from "crypto";
import {
createTrace,
createTracesCh,
createObservation,
createObservationsCh,
} from "@langfuse/shared/src/server";
import { type QueryType } from "@/src/features/query/types";
import { executeQuery } from "@/src/features/dashboard/server/dashboard-router";
import {
transformToPivotTable,
type DatabaseRow,
} from "@/src/features/widgets/utils/pivot-table-utils";
import { QueryBuilder } from "@/src/features/query/server/queryBuilder";
describe("Dashboard Router - Pivot Table Integration", () => {
// Single project ID for all tests
const projectId = randomUUID();
// Time references for test data
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 3600000);
const twoHoursAgo = new Date(now.getTime() - 7200000);
const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 3600000);
// Time ranges for queries - ISO format for query builder
const defaultFromTime = threeDaysAgo.toISOString();
const defaultToTime = new Date(now.getTime() + 3600000).toISOString(); // 1 hour in future
// Test data statistics for verification
const testDataStats = {
totalTraces: 0,
environmentCounts: {} as Record<string, number>,
modelCounts: {} as Record<string, number>,
totalObservations: 0,
};
beforeAll(async () => {
// Create diverse test data for pivot table testing
const traces = [
// Production environment traces
...Array(6)
.fill(0)
.map((_, i) =>
createTrace({
project_id: projectId,
name: "chat-completion",
environment: "production",
timestamp: now.getTime() - i * 10000,
user_id: `user-prod-${i}`,
}),
),
// Development environment traces
...Array(4)
.fill(0)
.map((_, i) =>
createTrace({
project_id: projectId,
name: "embeddings",
environment: "development",
timestamp: oneHourAgo.getTime() - i * 15000,
user_id: `user-dev-${i}`,
}),
),
// Staging environment traces
...Array(3)
.fill(0)
.map((_, i) =>
createTrace({
project_id: projectId,
name: "summarize",
environment: "staging",
timestamp: twoHoursAgo.getTime() - i * 20000,
user_id: `user-staging-${i}`,
}),
),
];
// Insert traces into ClickHouse
await createTracesCh(traces);
// Create observations with different models for each trace
const observations = [];
// Production observations - GPT models
for (let i = 0; i < 6; i++) {
const traceId = traces[i].id;
observations.push(
createObservation({
project_id: projectId,
trace_id: traceId,
name: "gpt-generation",
type: "generation",
environment: "production",
start_time: now.getTime() - i * 10000,
completion_start_time: now.getTime() - i * 10000 + 500,
end_time: now.getTime() - i * 10000 + 2000,
provided_model_name: i < 3 ? "gpt-4-turbo" : "gpt-3.5-turbo",
}),
);
}
// Development observations - Claude models
for (let i = 0; i < 4; i++) {
const traceId = traces[6 + i].id;
observations.push(
createObservation({
project_id: projectId,
trace_id: traceId,
name: "claude-generation",
type: "generation",
environment: "development",
start_time: oneHourAgo.getTime() - i * 15000,
completion_start_time: oneHourAgo.getTime() - i * 15000 + 800,
end_time: oneHourAgo.getTime() - i * 15000 + 3000,
provided_model_name: i < 2 ? "claude-3-opus" : "claude-3-sonnet",
}),
);
}
// Staging observations - Mixed models
for (let i = 0; i < 3; i++) {
const traceId = traces[10 + i].id;
observations.push(
createObservation({
project_id: projectId,
trace_id: traceId,
name: "mixed-generation",
type: "generation",
environment: "staging",
start_time: twoHoursAgo.getTime() - i * 20000,
completion_start_time: twoHoursAgo.getTime() - i * 20000 + 600,
end_time: twoHoursAgo.getTime() - i * 20000 + 2500,
provided_model_name: "gpt-4-turbo",
}),
);
}
// Insert observations into ClickHouse
await createObservationsCh(observations);
// Calculate test data statistics for verification
testDataStats.totalTraces = traces.length;
testDataStats.totalObservations = observations.length;
// Count by environment
traces.forEach((trace) => {
testDataStats.environmentCounts[trace.environment] =
(testDataStats.environmentCounts[trace.environment] || 0) + 1;
});
// Count by model
observations.forEach((obs) => {
if (obs.provided_model_name) {
testDataStats.modelCounts[obs.provided_model_name] =
(testDataStats.modelCounts[obs.provided_model_name] || 0) + 1;
}
});
});
describe("executeQuery function with pivot table configurations", () => {
it("should execute zero-dimension pivot table query (grand total only)", async () => {
const query: QueryType = {
view: "traces",
dimensions: [],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: {
granularity: "day",
},
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
chartConfig: {
type: "PIVOT_TABLE",
row_limit: 20,
},
};
const result = await executeQuery(projectId, query);
// Verify basic query execution
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
// Results should be grouped by time dimension when timeDimension is present
expect(result.length).toBeGreaterThan(0);
// Each row should have time_dimension and aggregated count
result.forEach((row) => {
expect(row).toHaveProperty("time_dimension");
expect(row).toHaveProperty("count_count");
expect(typeof row.count_count).toBe("string"); // ClickHouse returns numbers as strings
});
// Sum all counts to verify total
const totalCount = result.reduce(
(sum, row) => sum + parseInt(row.count_count as string),
0,
);
expect(totalCount).toBe(testDataStats.totalTraces);
});
it("should execute single-dimension pivot table query with environment grouping", async () => {
const query: QueryType = {
view: "traces",
dimensions: [{ field: "environment" }],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: {
granularity: "day",
},
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: [{ field: "environment", direction: "asc" }],
chartConfig: {
type: "PIVOT_TABLE",
row_limit: 20,
},
};
const result = await executeQuery(projectId, query);
// Verify query execution
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
// Should have one row per environment per time dimension
expect(result.length).toBeGreaterThan(0);
// Verify structure of results
result.forEach((row) => {
expect(row).toHaveProperty("environment");
expect(row).toHaveProperty("time_dimension");
expect(row).toHaveProperty("count_count");
expect(typeof row.environment).toBe("string");
expect(typeof row.count_count).toBe("string"); // ClickHouse returns numbers as strings
});
// Verify data accuracy by checking environment counts
const environmentTotals = result.reduce(
(acc, row) => {
const env = row.environment as string;
acc[env] =
((acc[env] as number) || 0) + parseInt(row.count_count as string);
return acc;
},
{} as Record<string, number>,
);
expect(environmentTotals["production"]).toBe(
testDataStats.environmentCounts["production"],
);
expect(environmentTotals["development"]).toBe(
testDataStats.environmentCounts["development"],
);
expect(environmentTotals["staging"]).toBe(
testDataStats.environmentCounts["staging"],
);
});
it("should execute two-dimension pivot table query with environment and model grouping", async () => {
const query: QueryType = {
view: "observations",
dimensions: [{ field: "environment" }, { field: "providedModelName" }],
metrics: [
{ measure: "count", aggregation: "count" },
{ measure: "totalTokens", aggregation: "sum" },
],
filters: [],
timeDimension: {
granularity: "day",
},
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: [
{ field: "environment", direction: "asc" },
{ field: "providedModelName", direction: "asc" },
],
chartConfig: {
type: "PIVOT_TABLE",
row_limit: 20,
},
};
const result = await executeQuery(projectId, query);
// Verify query execution
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBeGreaterThan(0);
// Should have combinations of environment and model
result.forEach((row) => {
expect(row).toHaveProperty("environment");
expect(row).toHaveProperty("providedModelName");
expect(row).toHaveProperty("time_dimension");
expect(row).toHaveProperty("count_count");
expect(row).toHaveProperty("sum_totalTokens");
expect(typeof row.environment).toBe("string");
expect(typeof row.providedModelName).toBe("string");
expect(typeof row.count_count).toBe("string");
expect(typeof row.sum_totalTokens).toBe("string");
});
// Verify total observation count matches test data
const totalObservations = result.reduce(
(sum, row) => sum + parseInt(row.count_count as string),
0,
);
expect(totalObservations).toBe(testDataStats.totalObservations);
});
it("should handle empty results gracefully", async () => {
const futureTime = new Date(now.getTime() + 86400000).toISOString(); // 1 day in future
const farFutureTime = new Date(now.getTime() + 172800000).toISOString(); // 2 days in future
const query: QueryType = {
view: "traces",
dimensions: [{ field: "environment" }],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: {
granularity: "day",
},
fromTimestamp: futureTime,
toTimestamp: farFutureTime,
orderBy: null,
chartConfig: {
type: "PIVOT_TABLE",
row_limit: 20,
},
};
const result = await executeQuery(projectId, query);
// Should handle empty results without errors
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
// Time dimension queries can return rows with 0 counts for time filling
expect(result.length).toBeGreaterThanOrEqual(0);
});
});
describe("Query Builder integration with pivot table configurations", () => {
it("should generate correct SQL for zero-dimension pivot table", () => {
const query: QueryType = {
view: "traces",
dimensions: [],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: {
granularity: "day",
},
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
chartConfig: {
type: "PIVOT_TABLE",
row_limit: 20,
},
};
const queryBuilder = new QueryBuilder(query.chartConfig);
const { query: sql, parameters } = queryBuilder.build(query, projectId);
// Verify SQL generation
expect(sql).toBeDefined();
expect(typeof sql).toBe("string");
expect(parameters).toBeDefined();
expect(typeof parameters).toBe("object");
// SQL should contain GROUP BY for time dimension when timeDimension is present
expect(sql.toLowerCase()).toContain("group by");
// Should contain aggregation
expect(sql.toLowerCase()).toContain("count(");
});
it("should generate correct SQL for single-dimension pivot table", () => {
const query: QueryType = {
view: "traces",
dimensions: [{ field: "environment" }],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: {
granularity: "day",
},
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: [{ field: "environment", direction: "asc" }],
chartConfig: {
type: "PIVOT_TABLE",
row_limit: 20,
},
};
const queryBuilder = new QueryBuilder(query.chartConfig);
const { query: sql, parameters } = queryBuilder.build(query, projectId);
// Verify SQL generation
expect(sql).toBeDefined();
expect(typeof sql).toBe("string");
expect(parameters).toBeDefined();
// SQL should contain GROUP BY for dimension
expect(sql.toLowerCase()).toContain("group by");
expect(sql.toLowerCase()).toContain("environment");
// Should contain ORDER BY
expect(sql.toLowerCase()).toContain("order by");
});
it("should generate correct SQL for two-dimension pivot table", () => {
const query: QueryType = {
view: "observations",
dimensions: [{ field: "environment" }, { field: "providedModelName" }],
metrics: [
{ measure: "count", aggregation: "count" },
{ measure: "totalTokens", aggregation: "sum" },
],
filters: [],
timeDimension: {
granularity: "day",
},
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: [
{ field: "environment", direction: "asc" },
{ field: "providedModelName", direction: "asc" },
],
chartConfig: {
type: "PIVOT_TABLE",
row_limit: 20,
},
};
const queryBuilder = new QueryBuilder(query.chartConfig);
const { query: sql, parameters } = queryBuilder.build(query, projectId);
// Verify SQL generation
expect(sql).toBeDefined();
expect(typeof sql).toBe("string");
expect(parameters).toBeDefined();
// SQL should contain GROUP BY for both dimensions
expect(sql.toLowerCase()).toContain("group by");
expect(sql.toLowerCase()).toContain("environment");
expect(sql.toLowerCase()).toContain("providedmodelname");
// Should contain multiple aggregations
expect(sql.toLowerCase()).toContain("count(");
expect(sql.toLowerCase()).toContain("sum(");
// Should contain ORDER BY for both dimensions
expect(sql.toLowerCase()).toContain("order by");
});
});
describe("End-to-end pivot table data transformation", () => {
it("should transform query results to pivot table structure for zero dimensions", async () => {
const query: QueryType = {
view: "traces",
dimensions: [],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: null, // No time dimension for simpler test
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
chartConfig: {
type: "PIVOT_TABLE",
row_limit: 20,
},
};
// Execute query to get raw data
const rawData = await executeQuery(projectId, query);
// Transform to pivot table structure
const pivotTableData = transformToPivotTable(rawData as DatabaseRow[], {
dimensions: [],
metrics: ["count_count"],
rowLimit: 20,
});
// Verify transformation
expect(pivotTableData).toBeDefined();
expect(Array.isArray(pivotTableData)).toBe(true);
expect(pivotTableData.length).toBe(1); // Should have only grand total
const totalRow = pivotTableData[0];
expect(totalRow.type).toBe("total");
expect(totalRow.level).toBe(0);
expect(totalRow.label).toBe("Total");
expect(totalRow.isTotal).toBe(true);
expect(totalRow.values).toHaveProperty("count_count");
});
it("should transform query results to pivot table structure for single dimension", async () => {
const query: QueryType = {
view: "traces",
dimensions: [{ field: "environment" }],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: null, // No time dimension for simpler test
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: [{ field: "environment", direction: "asc" }],
chartConfig: {
type: "PIVOT_TABLE",
row_limit: 20,
},
};
// Execute query to get raw data
const rawData = await executeQuery(projectId, query);
// Transform to pivot table structure
const pivotTableData = transformToPivotTable(rawData as DatabaseRow[], {
dimensions: ["environment"],
metrics: ["count_count"],
rowLimit: 20,
});
// Verify transformation
expect(pivotTableData).toBeDefined();
expect(Array.isArray(pivotTableData)).toBe(true);
expect(pivotTableData.length).toBeGreaterThan(1); // Should have data rows + total
// Should have data rows for each environment
const dataRows = pivotTableData.filter((row) => row.type === "data");
const totalRow = pivotTableData.find((row) => row.type === "total");
expect(dataRows.length).toBeGreaterThan(0);
expect(totalRow).toBeDefined();
expect(totalRow!.isTotal).toBe(true);
// Verify data row structure
dataRows.forEach((row) => {
expect(row.type).toBe("data");
expect(row.level).toBe(0);
expect(row.values).toHaveProperty("count_count");
expect(typeof row.values.count_count).toBe("number");
});
});
it("should transform query results to pivot table structure for two dimensions", async () => {
const query: QueryType = {
view: "observations",
dimensions: [{ field: "environment" }, { field: "providedModelName" }],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: null, // No time dimension for simpler test
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: [
{ field: "environment", direction: "asc" },
{ field: "providedModelName", direction: "asc" },
],
chartConfig: {
type: "PIVOT_TABLE",
row_limit: 20,
},
};
// Execute query to get raw data
const rawData = await executeQuery(projectId, query);
// Transform to pivot table structure
const pivotTableData = transformToPivotTable(rawData as DatabaseRow[], {
dimensions: ["environment", "providedModelName"],
metrics: ["count_count"],
rowLimit: 20,
});
// Verify transformation
expect(pivotTableData).toBeDefined();
expect(Array.isArray(pivotTableData)).toBe(true);
expect(pivotTableData.length).toBeGreaterThan(1);
// Should have nested structure with data rows, subtotals, and grand total
const dataRows = pivotTableData.filter((row) => row.type === "data");
const subtotalRows = pivotTableData.filter(
(row) => row.type === "subtotal",
);
const totalRow = pivotTableData.find((row) => row.type === "total");
expect(dataRows.length).toBeGreaterThan(0);
expect(totalRow).toBeDefined();
// Verify indentation levels
dataRows.forEach((row) => {
expect(row.level).toBe(1); // Second level for two dimensions
expect(row.values).toHaveProperty("count_count");
});
if (subtotalRows.length > 0) {
subtotalRows.forEach((row) => {
expect(row.level).toBe(0); // First level subtotals
expect(row.isSubtotal).toBe(true);
});
}
expect(totalRow!.level).toBe(0);
expect(totalRow!.isTotal).toBe(true);
});
});
describe("Error handling and edge cases", () => {
it("should handle empty results gracefully in transformation", async () => {
// Transform empty data to pivot table structure
const pivotTableData = transformToPivotTable([], {
dimensions: ["environment"],
metrics: ["count_count"],
rowLimit: 20,
});
// Should handle empty data gracefully
expect(pivotTableData).toBeDefined();
expect(Array.isArray(pivotTableData)).toBe(true);
// Should still have total row with zero values
expect(pivotTableData.length).toBe(1);
const totalRow = pivotTableData[0];
expect(totalRow.type).toBe("total");
expect(totalRow.values.count_count).toBe(0);
});
it("should handle missing project data gracefully", async () => {
const nonExistentProjectId = randomUUID();
const query: QueryType = {
view: "traces",
dimensions: [],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: {
granularity: "day",
},
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
chartConfig: {
type: "PIVOT_TABLE",
row_limit: 20,
},
};
const result = await executeQuery(nonExistentProjectId, query);
// Should return results even for non-existent project (time fill creates rows)
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBeGreaterThanOrEqual(0);
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,383 @@
/**
* @fileoverview Unit Tests for PivotTable React Component
*
* Comprehensive test suite for the PivotTable component functionality including:
* - Component rendering with various data scenarios
* - Proper styling and CSS class application
* - Indentation behavior for nested dimensions
* - Empty data and error state handling
* - Metric value formatting and display
* - Column header formatting
*
* Uses Jest and React Testing Library for component testing.
* This test focuses on component behavior rather than data transformation logic.
*/
import React from "react";
import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom";
import {
PivotTable,
type PivotTableProps,
} from "@/src/features/widgets/chart-library/PivotTable";
import { type DataPoint } from "@/src/features/widgets/chart-library/chart-props";
describe("PivotTable Component", () => {
describe("Basic Rendering", () => {
test("renders table with simple data", () => {
const data: DataPoint[] = [
{
time_dimension: "2024-01-01",
dimension: "gpt-4",
metric: 100,
},
{
time_dimension: "2024-01-01",
dimension: "gpt-3.5",
metric: 75,
},
];
const props: PivotTableProps = {
data,
config: {
dimensions: ["model"],
metrics: ["metric"],
},
};
render(<PivotTable {...props} />);
// Check table structure exists
expect(screen.getByRole("table")).toBeInTheDocument();
// Should have dimension column and metric column
expect(screen.getAllByRole("columnheader")).toHaveLength(2);
});
test("displays data when no configuration provided", () => {
const data: DataPoint[] = [
{
time_dimension: "2024-01-01",
dimension: "test",
metric: 50,
},
];
const props: PivotTableProps = {
data,
};
render(<PivotTable {...props} />);
// Should still render a table
expect(screen.getByRole("table")).toBeInTheDocument();
});
});
describe("Column Header Formatting", () => {
test("formats single dimension header correctly", () => {
const data: DataPoint[] = [
{
time_dimension: "2024-01-01",
dimension: "test",
metric: 100,
},
];
const props: PivotTableProps = {
data,
config: {
dimensions: ["model_name"],
metrics: ["request_count"],
},
};
render(<PivotTable {...props} />);
expect(
screen.getByRole("columnheader", { name: "Model Name" }),
).toBeInTheDocument();
expect(
screen.getByRole("columnheader", { name: "Request Count" }),
).toBeInTheDocument();
});
test("formats multiple dimension headers correctly", () => {
const data: DataPoint[] = [
{
time_dimension: "2024-01-01",
dimension: "test",
metric: 100,
},
];
const props: PivotTableProps = {
data,
config: {
dimensions: ["model_name", "time_period"],
metrics: ["request_count", "avg_duration"],
},
};
render(<PivotTable {...props} />);
expect(
screen.getByRole("columnheader", { name: "Model Name / Time Period" }),
).toBeInTheDocument();
expect(
screen.getByRole("columnheader", { name: "Request Count" }),
).toBeInTheDocument();
expect(
screen.getByRole("columnheader", { name: "Avg Duration" }),
).toBeInTheDocument();
});
test("defaults to 'Dimension' when no dimensions configured", () => {
const data: DataPoint[] = [
{
time_dimension: "2024-01-01",
dimension: "test",
metric: 100,
},
];
const props: PivotTableProps = {
data,
config: {
dimensions: [],
metrics: ["metric"],
},
};
render(<PivotTable {...props} />);
expect(
screen.getByRole("columnheader", { name: "Dimension" }),
).toBeInTheDocument();
});
});
describe("Empty Data Handling", () => {
test("displays 'No data available' for empty data array", () => {
const props: PivotTableProps = {
data: [],
config: {
dimensions: ["model"],
metrics: ["metric"],
},
};
render(<PivotTable {...props} />);
expect(screen.getByText("No data available")).toBeInTheDocument();
expect(screen.queryByRole("table")).not.toBeInTheDocument();
});
test("displays 'No data available' for null data", () => {
const props: PivotTableProps = {
data: null as any,
config: {
dimensions: ["model"],
metrics: ["metric"],
},
};
render(<PivotTable {...props} />);
expect(screen.getByText("No data available")).toBeInTheDocument();
expect(screen.queryByRole("table")).not.toBeInTheDocument();
});
test("displays 'No data available' for undefined data", () => {
const props: PivotTableProps = {
data: undefined as any,
config: {
dimensions: ["model"],
metrics: ["metric"],
},
};
render(<PivotTable {...props} />);
expect(screen.getByText("No data available")).toBeInTheDocument();
expect(screen.queryByRole("table")).not.toBeInTheDocument();
});
});
describe("Responsive Design", () => {
test("includes overflow-auto class for responsive scrolling", () => {
const data: DataPoint[] = [
{
time_dimension: "2024-01-01",
dimension: "test",
metric: 100,
},
];
const props: PivotTableProps = {
data,
config: {
dimensions: ["model"],
metrics: ["metric"],
},
};
const { container } = render(<PivotTable {...props} />);
const pivotTableContainer = container.firstChild as HTMLElement;
expect(pivotTableContainer).toHaveClass("h-full", "overflow-auto");
});
});
describe("Accessibility", () => {
test("provides proper table structure for screen readers", () => {
const data: DataPoint[] = [
{
time_dimension: "2024-01-01",
dimension: "gpt-4",
metric: 100,
},
];
const props: PivotTableProps = {
data,
config: {
dimensions: ["model"],
metrics: ["metric"],
},
};
render(<PivotTable {...props} />);
// Check table has proper semantic structure
expect(screen.getByRole("table")).toBeInTheDocument();
expect(screen.getAllByRole("columnheader")).toHaveLength(2);
// Should have at least header row
expect(screen.getAllByRole("row").length).toBeGreaterThanOrEqual(1);
});
test("provides proper cell associations", () => {
const data: DataPoint[] = [
{
time_dimension: "2024-01-01",
dimension: "test",
metric: 100,
},
];
const props: PivotTableProps = {
data,
config: {
dimensions: ["model"],
metrics: ["metric"],
},
};
render(<PivotTable {...props} />);
// Header cells should be properly associated
const headers = screen.getAllByRole("columnheader");
expect(headers.length).toBeGreaterThan(0);
// Each header should be part of a row
headers.forEach((header) => {
expect(header.closest("tr")).toBeInTheDocument();
});
});
});
describe("Component Props", () => {
test("handles missing config gracefully", () => {
const data: DataPoint[] = [
{
time_dimension: "2024-01-01",
dimension: "test",
metric: 100,
},
];
const props: PivotTableProps = {
data,
// No config provided
};
render(<PivotTable {...props} />);
// Should still render a table
expect(screen.getByRole("table")).toBeInTheDocument();
});
test("passes accessibilityLayer prop correctly", () => {
const data: DataPoint[] = [
{
time_dimension: "2024-01-01",
dimension: "test",
metric: 100,
},
];
const props: PivotTableProps = {
data,
accessibilityLayer: true,
};
// Should render without error
expect(() => render(<PivotTable {...props} />)).not.toThrow();
});
});
describe("Data Processing Integration", () => {
test("handles various metric data types", () => {
const data: DataPoint[] = [
{
time_dimension: "2024-01-01",
dimension: "test1",
metric: 100, // number
},
{
time_dimension: "2024-01-01",
dimension: "test2",
metric: [
[10, 20],
[30, 40],
], // nested array
},
];
const props: PivotTableProps = {
data,
config: {
dimensions: ["model"],
metrics: ["metric"],
},
};
// Should render without error
expect(() => render(<PivotTable {...props} />)).not.toThrow();
expect(screen.getByRole("table")).toBeInTheDocument();
});
test("handles missing dimension data", () => {
const data: DataPoint[] = [
{
time_dimension: "2024-01-01",
dimension: undefined, // missing dimension
metric: 100,
},
];
const props: PivotTableProps = {
data,
config: {
dimensions: ["model"],
metrics: ["metric"],
},
};
// Should render without error
expect(() => render(<PivotTable {...props} />)).not.toThrow();
expect(screen.getByRole("table")).toBeInTheDocument();
});
});
});
+1 -1
View File
@@ -8,7 +8,7 @@ import {
LegacyPromptSchema,
PromptType,
type LegacyValidatedPrompt,
} from "@/src/features/prompts/server/utils/validation";
} from "@langfuse/shared";
import { getObservationById } from "@langfuse/shared/src/server";
describe("/api/public/prompts API Endpoint", () => {
+128 -8
View File
@@ -3,20 +3,22 @@
import { prisma } from "@langfuse/shared/src/db";
import { makeAPICall, pruneDatabase } from "@/src/__tests__/test-utils";
import { v4 as uuidv4, v4 } from "uuid";
import { type Prompt } from "@langfuse/shared";
import {
PromptSchema,
PromptType,
type ValidatedPrompt,
} from "@/src/features/prompts/server/utils/validation";
type ChatMessage,
type Prompt,
} from "@langfuse/shared";
import { parsePromptDependencyTags } from "@langfuse/shared";
import { nanoid } from "ai";
import { generateId, nanoid } from "ai";
import { type PromptsMetaResponse } from "@/src/features/prompts/server/actions/getPromptsMeta";
import {
createOrgProjectAndApiKey,
getObservationById,
MAX_PROMPT_NESTING_DEPTH,
ChatMessageType,
} from "@langfuse/shared/src/server";
import { randomUUID } from "node:crypto";
@@ -452,6 +454,52 @@ describe("/api/public/v2/prompts API Endpoint", () => {
expect(validatedPrompt.commitMessage).toBe("chore: setup initial prompt");
});
it("should create and fetch a chat prompt with message placeholders", async () => {
const promptName = `prompt-name-message-placeholders${generateId()}`;
const commitMessage = "feat: add message placeholders support";
const chatMessages = [
{ role: "system", content: "You are a helpful assistant with conversation context." },
{
type: ChatMessageType.Placeholder,
name: "conversation_history"
},
{ role: "user", content: "{{user_question}}" }
];
const response = await makeAPICall("POST", baseURI, {
name: promptName,
prompt: chatMessages,
type: "chat",
labels: ["production"],
commitMessage: commitMessage
});
expect(response.status).toBe(201);
const { body: fetchedPrompt } = await makeAPICall(
"GET",
`${baseURI}/${promptName}`,
undefined,
);
const validatedPrompt = validatePrompt(fetchedPrompt);
expect(validatedPrompt.name).toBe(promptName);
expect(validatedPrompt.prompt).toEqual(chatMessages);
expect(validatedPrompt.type).toBe("chat");
expect(validatedPrompt.version).toBe(1);
expect(validatedPrompt.labels).toEqual(["production", "latest"]);
expect(validatedPrompt.createdBy).toBe("API");
expect(validatedPrompt.config).toEqual({});
expect(validatedPrompt.commitMessage).toBe(commitMessage);
// Verify the placeholder message structure is preserved
const messages = validatedPrompt.prompt as ChatMessage[];
const placeholderMessage = messages[1] as { type: ChatMessageType.Placeholder; name: string };
expect(placeholderMessage.type).toBe(ChatMessageType.Placeholder);
expect(placeholderMessage.name).toBe("conversation_history");
});
it("should fail if chat prompt has string prompt", async () => {
const promptName = "prompt-name";
const response = await makeAPICall("POST", baseURI, {
@@ -705,7 +753,9 @@ describe("/api/public/v2/prompts API Endpoint", () => {
});
expect(response.status).toBe(400);
expect(response.body.message).toBe("Invalid request data");
const hasExpectedMessage = JSON.stringify(response.body.error).includes(`"message":"${expectedError}"`);
const hasExpectedMessage = JSON.stringify(response.body.error).includes(
`"message":"${expectedError}"`,
);
expect(hasExpectedMessage).toBe(true);
};
@@ -723,10 +773,19 @@ describe("/api/public/v2/prompts API Endpoint", () => {
it("should reject invalid prompt names", async () => {
// Test invalid patterns
await testInvalidName("/invalid-name", "Name cannot start with a slash");
await testInvalidName(
"/invalid-name",
"Name cannot start with a slash",
);
await testInvalidName("invalid-name/", "Name cannot end with a slash");
await testInvalidName("invalid//name", "Name cannot contain consecutive slashes");
await testInvalidName("invalid|name", "Prompt name cannot contain '|' character");
await testInvalidName(
"invalid//name",
"Name cannot contain consecutive slashes",
);
await testInvalidName(
"invalid|name",
"Prompt name cannot contain '|' character",
);
await testInvalidName("new", "Prompt name cannot be 'new'");
await testInvalidName("", "Enter a name");
});
@@ -848,7 +907,68 @@ describe("/api/public/v2/prompts API Endpoint", () => {
// Verify the name with slashes is preserved
expect(validatedPrompt.name).toBe(promptName);
expect(validatedPrompt.name).toContain("/");
expect(validatedPrompt.prompt).toBe("This is a prompt in a folder structure");
expect(validatedPrompt.prompt).toBe(
"This is a prompt in a folder structure",
);
});
it("should prevent creating a prompt with both a variable and placeholder with the same name", async () => {
const promptName = "prompt-same-name-conflict-" + nanoid();
// Try to create a prompt where the same name is used as both a variable and a placeholder
const response = await makeAPICall("POST", baseURI, {
name: promptName,
prompt: [
{ role: "system", content: "Hello {{userName}}" },
{ type: ChatMessageType.Placeholder, name: "userName" },
{ role: "user", content: "How are you?" }
],
type: "chat",
});
// This should fail with a 400 error
expect(response.status).toBe(400);
expect(response.body).toHaveProperty("error");
expect(response.body).toHaveProperty("message");
// @ts-expect-error
expect(response.body.message).toContain("variables and placeholders must be unique");
// @ts-expect-error
expect(response.body.message).toContain("userName");
});
it("should allow creating a new version of a prompt with placeholder names that conflict a variable name in a previous version", async () => {
const promptName = "prompt-with-variable-conflict-" + nanoid();
// First, create a chat prompt with a message variable
const v1Response = await makeAPICall("POST", baseURI, {
name: promptName,
prompt: [
{ role: "system", content: "You are a helpful {{conversationHistory}}" },
{ role: "user", content: "Continue our conversation" }
],
type: "chat",
labels: ["production"],
});
expect(v1Response.status).toBe(201);
// Try to create a new version with a text variable that has the same name as the placeholder
const v2Response = await makeAPICall("POST", baseURI, {
name: promptName,
prompt: [
{ role: "system", content: "You are a helpful assistant with context: {{newHistory}}" },
{ type: "placeholder", name: "conversationHistory" },
{ role: "user", content: "Continue our conversation" }
],
type: "chat"
});
// This should succeed, we allow cross-version name reuse
expect(v2Response.status).toBe(201);
expect(v2Response.body).toHaveProperty("id");
expect(v2Response.body).toHaveProperty("version");
// @ts-expect-error - Response body type is flexible for testing
expect(v2Response.body.version).toBe(2);
});
});
+3 -1
View File
@@ -3427,7 +3427,9 @@ describe("queryBuilder", () => {
};
// Execute histogram query with custom bins
const queryBuilder = new QueryBuilder(customBinHistogramQuery.chartConfig);
const queryBuilder = new QueryBuilder(
customBinHistogramQuery.chartConfig,
);
const { query: compiledQuery, parameters } = queryBuilder.build(
customBinHistogramQuery,
projectId,
@@ -8,6 +8,7 @@ import {
SYSTEM_ROLES,
type ChatMessageWithId,
type LLMToolCall,
type PlaceholderMessage,
} from "@langfuse/shared";
import { Button } from "@/src/components/ui/button";
import { Card, CardContent } from "@/src/components/ui/card";
@@ -45,15 +46,17 @@ const ROLES: ChatMessageRole[] = [
const getRoleNamePlaceholder = (role: string) => {
switch (role) {
case ChatMessageRole.System:
return "a system";
return "a system message";
case ChatMessageRole.Developer:
return "a developer";
return "a developer message";
case ChatMessageRole.Assistant:
return "an assistant";
return "an assistant message";
case ChatMessageRole.User:
return "a user";
return "a user message";
case ChatMessageRole.Tool:
return "a tool response";
return "a tool response message";
case "placeholder":
return "placeholder name (e.g. msg_history)";
default:
return `a ${role}`;
}
@@ -92,6 +95,9 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
} = useSortable({ id: message.id });
const toggleRole = () => {
// Only allow role toggling for messages that have a role property (not placeholder messages)
if (!('role' in message)) return;
// if user has set custom roles, available roles will be non-empty and we toggle through custom and default roles (assistant, user)
if (!!availableRoles && Boolean(availableRoles.length)) {
let randomRole = availableRoles[roleIndex % availableRoles.length];
@@ -113,7 +119,7 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
(toolCallIds && toolCallIds.length > 0),
);
const currentIndex = eligibleRoles.indexOf(
message.role as ChatMessageRole,
('role' in message ? message.role : ChatMessageRole.User) as ChatMessageRole,
);
const nextRole =
eligibleRoles[(currentIndex + 1) % eligibleRoles.length];
@@ -196,13 +202,28 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
};
const onValueChange = useCallback(
(value: string) =>
updateMessage(message.type, message.id, "content", value),
(value: string) => {
if (message.type === ChatMessageType.Placeholder) {
updateMessage(message.type, message.id, "name", value);
} else {
updateMessage(message.type, message.id, "content", value);
}
},
[message.id, message.type, updateMessage],
);
const showDragHandle = !SYSTEM_ROLES.includes(message.role);
const onPlaceholderNameChange = useCallback(
(value: string) => {
if (message.type === ChatMessageType.Placeholder) {
updateMessage(message.type, message.id, "name", value);
}
},
[message.id, message.type, updateMessage],
);
const showDragHandle = !('role' in message && SYSTEM_ROLES.includes(message.role));
const showToolCallSelect = message.type === ChatMessageType.ToolResult;
const isPlaceholder = message.type === ChatMessageType.Placeholder;
return (
<Card
@@ -233,14 +254,20 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
)}
>
<div className="flex w-[4rem] flex-shrink-0 flex-col gap-1">
<Button
onClick={toggleRole}
type="button"
variant="ghost"
className="h-6 w-full px-1 py-0 text-[10px] font-semibold text-muted-foreground hover:bg-accent hover:text-accent-foreground"
>
{capitalize(message.role)}
</Button>
{isPlaceholder ? (
<span className="inline-flex items-center justify-center font-mono h-6 w-full text-[9px] rounded-md bg-accent px-4 text-muted-foreground">
placeholder
</span>
) : (
<Button
onClick={toggleRole}
type="button"
variant="ghost"
className="h-6 w-full px-1 py-0 text-[10px] font-semibold text-muted-foreground hover:bg-accent hover:text-accent-foreground"
>
{capitalize(message.role)}
</Button>
)}
</div>
<div className="flex flex-1 flex-col gap-1">
<div className="flex gap-2">
@@ -271,11 +298,19 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
</SelectContent>
</Select>
)}
<MemoizedEditor
value={message.content}
onChange={onValueChange}
role={message.role}
/>
{isPlaceholder ? (
<MemoizedEditor
value={(message as PlaceholderMessage).name || ""}
onChange={onPlaceholderNameChange}
role={message.type}
/>
) : (
<MemoizedEditor
value={message.content}
onChange={onValueChange}
role={message.role}
/>
)}
</div>
{message.type === ChatMessageType.AssistantToolCall && (
<ToolCalls toolCalls={message.toolCalls as LLMToolCall[]} />
@@ -303,7 +338,7 @@ const MemoizedEditor = memo(function MemoizedEditor(props: {
onChange: (value: string) => void;
}) {
const { value, role, onChange } = props;
const placeholder = `Enter ${getRoleNamePlaceholder(role)} message here.`;
const placeholder = `Enter ${getRoleNamePlaceholder(role)} here.`;
return (
<CodeMirrorEditor
+65 -26
View File
@@ -2,9 +2,16 @@ import { PlusCircleIcon } from "lucide-react";
import { useEffect, useRef } from "react";
import { Button } from "@/src/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/src/components/ui/tooltip";
import {
ChatMessageRole,
ChatMessageType,
type ChatMessageWithId,
SYSTEM_ROLES,
} from "@langfuse/shared";
@@ -60,8 +67,9 @@ export const ChatMessages: React.FC<ChatMessagesProps> = (props) => {
if (newIndex < 0 || oldIndex < 0) {
return;
}
// prevent reordering system messages
if (SYSTEM_ROLES.includes(messages[newIndex].role)) {
// prevent reordering system messages, placeholders can be reordered
const targetMessage = messages[newIndex];
if (targetMessage.type !== ChatMessageType.Placeholder && SYSTEM_ROLES.includes(targetMessage.role)) {
return;
}
const newMessages = arrayMove(messages, oldIndex, newIndex);
@@ -114,35 +122,66 @@ const AddMessageButton: React.FC<AddMessageButtonProps> = ({
messages,
addMessage,
}) => {
const lastMessageRole = messages[messages.length - 1]?.role;
// Skip placeholder messages when determining last roles
const lastMessageWithRole = messages.slice().reverse().find((msg): msg is ChatMessageWithId & { role: string } => msg.type !== ChatMessageType.Placeholder);
const lastMessageRole = lastMessageWithRole?.role;
const nextMessageRole =
lastMessageRole === ChatMessageRole.User
? ChatMessageRole.Assistant
: ChatMessageRole.User;
const addRegularMessage = () => {
if (nextMessageRole === ChatMessageRole.User) {
addMessage({
role: nextMessageRole,
content: "",
type: ChatMessageType.User,
});
} else {
addMessage({
role: nextMessageRole,
content: "",
type: ChatMessageType.AssistantText,
});
}
};
const addPlaceholderMessage = () => {
addMessage({
type: ChatMessageType.Placeholder,
name: "",
});
};
return (
<Button
type="button" // prevents submitting a form if this button is inside a form
variant="outline"
className="w-full"
onClick={() => {
if (nextMessageRole === ChatMessageRole.User) {
addMessage({
role: nextMessageRole,
content: "",
type: ChatMessageType.User,
});
} else {
addMessage({
role: nextMessageRole,
content: "",
type: ChatMessageType.AssistantText,
});
}
}}
>
<PlusCircleIcon size={14} className="mr-2" />
<p>Add message</p>
</Button>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={addRegularMessage}
>
<PlusCircleIcon size={14} className="mr-2" />
<p>Add message</p>
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
className="flex-1"
onClick={addPlaceholderMessage}
>
<PlusCircleIcon size={14} className="mr-2" />
<p>Add message placeholder</p>
</Button>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">Adds a placeholder to inject message pairs, e.g. a message history (with &quot;role&quot;, &quot;content&quot; pairs) when compiling the message in the SDK.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
};
+3 -3
View File
@@ -1,15 +1,15 @@
import type { ChatMessage, ChatMessageWithId } from "@langfuse/shared";
import type { ChatMessage, ChatMessageWithId, PlaceholderMessage } from "@langfuse/shared";
export type MessagesContext = {
messages: ChatMessageWithId[];
addMessage: (message: ChatMessage) => ChatMessageWithId;
addMessage: (message: ChatMessage | PlaceholderMessage) => ChatMessageWithId;
setMessages: (messages: ChatMessageWithId[]) => void;
deleteMessage: (id: string) => void;
updateMessage: <
T extends ChatMessageWithId["type"],
Key extends keyof Omit<
Extract<ChatMessageWithId, { type: T }>,
"id" | "type" | "role"
"id" | "type"
>,
Value = Extract<ChatMessageWithId, { type: T }>[Key],
>(
@@ -1,7 +1,7 @@
import { v4 as uuidv4 } from "uuid";
import { type ChatMessage, type ChatMessageWithId } from "@langfuse/shared";
import { type ChatMessage, type ChatMessageWithIdNoPlaceholders } from "@langfuse/shared";
export function createEmptyMessage(message: ChatMessage): ChatMessageWithId {
export function createEmptyMessage(message: ChatMessage): ChatMessageWithIdNoPlaceholders {
return {
...message,
content: message.content ?? "",
+1
View File
@@ -338,6 +338,7 @@ export function DeleteEvalConfigButton(props: DeleteButtonProps) {
source: isTableAction ? "table-single-row" : "eval config detail",
})
}
customDeletePrompt="This action cannot be undone and removes all logs associated with this running evaluator. Scores produced by this evaluator will not be deleted."
entityToDeleteName="running evaluator"
executeDeleteMutation={executeDeleteMutation}
isDeleteMutationLoading={evaluatorMutation.isLoading}
+2 -2
View File
@@ -289,13 +289,13 @@ export default function Layout(props: PropsWithChildren) {
rel="icon"
type="image/png"
sizes="32x32"
href={`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/favicon-32x32.png`}
href={`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/favicon-32x32${env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "DEV" ? "-dev" : ""}.png`}
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href={`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/favicon-16x16.png`}
href={`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/favicon-16x16${env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "DEV" ? "-dev" : ""}.png`}
/>
</Head>
<div>
@@ -29,6 +29,15 @@ type SidebarNotification = {
};
const notifications: SidebarNotification[] = [
{
id: "python-sdk-v3",
title: "New Python SDK v3",
description:
"Python SDK V3 offers significant improvements in developer experience, performance, and integrations.",
link: "https://langfuse.com/docs/sdk/python/sdk-v3#upgrade-from-v2",
linkTitle: "Upgrade to v3",
createdAt: "2025-06-27",
},
{
id: "lw3-5",
title: "Launch Week #3: Day 5",
@@ -102,7 +111,7 @@ const notifications: SidebarNotification[] = [
linkContent: (
// eslint-disable-next-line @next/next/no-img-element
<img
alt="Langfuse Github stars"
alt="Langfuse GitHub stars"
src="https://img.shields.io/github/stars/langfuse/langfuse?label=langfuse&amp;style=social"
/>
),
+2 -1
View File
@@ -125,11 +125,12 @@ export const ChatMlMessageSchema = z
...other,
...additional_kwargs,
}))
.transform(({ role, name, content, audio, ...other }) => ({
.transform(({ role, name, content, audio, type, ...other }) => ({
role,
name,
content,
audio,
type,
...(Object.keys(other).length === 0 ? {} : { json: other }),
}));
export type ChatMlMessageSchema = z.infer<typeof ChatMlMessageSchema>;
+49 -27
View File
@@ -234,10 +234,14 @@ export const OpenAiMessageView: React.FC<{
return !!message.json;
};
const isPlaceholderMessage = (message: ChatMlMessageSchema) => {
return message.type === 'placeholder';
};
const messagesToRender = useMemo(
() =>
messages.filter(
(message) => shouldRenderContent(message) || shouldRenderJson(message),
(message) => shouldRenderContent(message) || shouldRenderJson(message) || isPlaceholderMessage(message),
),
[messages],
);
@@ -257,39 +261,57 @@ export const OpenAiMessageView: React.FC<{
)
.map((message, index) => (
<Fragment key={index}>
{shouldRenderContent(message) &&
(shouldRenderMarkdown ? (
{isPlaceholderMessage(message) ? (
shouldRenderMarkdown ? (
<MarkdownJsonView
title={message.name ?? message.role}
content={message.content || '""'}
className={cn(!!message.json && "rounded-b-none")}
customCodeHeaderClassName={cn(
message.role === "assistant" && "bg-secondary",
message.role === "system" && "bg-primary-foreground",
)}
audio={message.audio}
title="Placeholder"
content={message.name || 'Unnamed placeholder'}
customCodeHeaderClassName={cn("bg-primary-foreground")}
/>
) : (
<JSONView
title={message.name ?? message.role}
json={message.content}
title="Placeholder"
json={message.name || 'Unnamed placeholder'}
projectIdForPromptButtons={projectIdForPromptButtons}
className={cn(!!message.json && "rounded-b-none")}
/>
))}
{shouldRenderJson(message) && (
<JSONView
title={
message.content
? undefined
: (message.name ?? message.role)
}
json={message.json}
projectIdForPromptButtons={projectIdForPromptButtons}
className={cn(
!!message.content && "rounded-t-none border-t-0",
)
) : (
<>
{shouldRenderContent(message) &&
(shouldRenderMarkdown ? (
<MarkdownJsonView
title={message.name ?? message.role}
content={message.content || '""'}
className={cn(!!message.json && !isPlaceholderMessage(message) && "rounded-b-none")}
customCodeHeaderClassName={cn(
message.role === "assistant" && "bg-secondary",
message.role === "system" && "bg-primary-foreground",
)}
audio={message.audio}
/>
) : (
<JSONView
title={message.name ?? message.role}
json={message.content}
projectIdForPromptButtons={projectIdForPromptButtons}
className={cn(!!message.json && !isPlaceholderMessage(message) && "rounded-b-none")}
/>
))}
{shouldRenderJson(message) && !isPlaceholderMessage(message) && (
<JSONView
title={
message.content
? undefined
: (message.name ?? message.role)
}
json={message.json}
projectIdForPromptButtons={projectIdForPromptButtons}
className={cn(
!!message.content && "rounded-t-none border-t-0",
)}
/>
)}
/>
</>
)}
{isCollapsed !== null && index === 0 ? (
<Button
+10 -9
View File
@@ -390,17 +390,18 @@ const ObservationTreeNodeCard = ({
)}
ref={currentObservationRef}
>
{/* Type badge */}
<ItemBadge
type={observation.type}
isSmall
className="flex-shrink-0 scale-75"
/>
{/* Type badge and name */}
<div className="flex items-center gap-2 flex-shrink-0">
<ItemBadge
type={observation.type}
isSmall
className="flex-shrink-0 scale-75"
/>
<span className="text-sm font-medium whitespace-nowrap">{observation.name}</span>
</div>
{/* Name and duration */}
{/* Duration and Comments */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{observation.name}</span>
{comments && showComments ? (
<CommentCountIcon count={comments.get(observation.id)} />
) : null}
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.75.3";
export const VERSION = "v3.78.1";
@@ -120,6 +120,7 @@ export const DatasetForm = (props: DatasetFormProps) => {
allNames: allDatasetNames,
form,
errorMessage: "Dataset name already exists.",
whitelistedName: props.mode === "update" ? props.datasetName : undefined,
});
function onSubmit(values: z.infer<typeof formSchema>) {
@@ -110,7 +110,11 @@ export function DatasetsTable(props: { projectId: string }) {
size: 200,
cell: ({ row }) => {
const description: RowData["description"] = row.getValue("description");
return <div className="h-full overflow-y-auto flex items-center">{description}</div>;
return (
<div className="flex h-full items-center overflow-y-auto">
{description}
</div>
);
},
},
{
@@ -189,6 +193,7 @@ export function DatasetsTable(props: { projectId: string }) {
datasetId={key.id}
datasetName={key.name}
datasetDescription={row.getValue("description") ?? undefined}
datasetMetadata={row.getValue("metadata") ?? undefined}
/>
</DropdownMenuItem>
<DropdownMenuItem asChild>
@@ -6,7 +6,7 @@ import { cn } from "@/src/utils/tailwind";
import { type RouterOutput } from "@/src/utils/types";
import { type EvalTemplate } from "@langfuse/shared";
import Link from "next/link";
import { Fragment } from "react";
import { Fragment, useMemo } from "react";
const VARIABLE_COLORS = [
"text-primary-accent",
@@ -128,9 +128,14 @@ export const EvaluationPromptPreview = ({
className?: string;
controlButtons?: React.ReactNode;
}) => {
const memoizedVariables = useMemo(
() => variableMapping.map(({ templateVariable }) => templateVariable),
[variableMapping],
);
const { extractedVariables, isExtracting } = useExtractVariables({
variables: variableMapping.map(({ templateVariable }) => templateVariable),
variableMapping: variableMapping,
variables: memoizedVariables,
variableMapping,
trace: trace,
isLoading,
});
@@ -396,21 +396,27 @@ export const InnerEvaluatorForm = (props: {
<div className="flex items-center gap-2">
{form.watch("target") === "trace" && !props.disabled && (
<>
<span className="text-xs text-muted-foreground">Show preview</span>
<span className="text-xs text-muted-foreground">Show Preview</span>
<Switch
checked={showPreview}
onCheckedChange={setShowPreview}
disabled={props.disabled}
/>
{traceWithObservations && showPreview && (
<DetailPageNav
currentId={traceWithObservations.id}
listKey="traces"
path={(entry) =>
`/project/${props.projectId}/evals/new?evaluator=${props.evalTemplate.id}&traceId=${entry.id}`
}
/>
)}
{showPreview &&
(traceWithObservations ? (
<DetailPageNav
currentId={traceWithObservations.id}
listKey="traces"
path={(entry) =>
`/project/${props.projectId}/evals/new?evaluator=${props.evalTemplate.id}&traceId=${entry.id}`
}
/>
) : (
<div className="flex flex-row gap-1">
<Skeleton className="h-8 w-[54px]" />
<Skeleton className="h-8 w-[54px]" />
</div>
))}
</>
)}
</div>
@@ -723,11 +729,12 @@ export const InnerEvaluatorForm = (props: {
controlButtons={mappingControlButtons}
/>
) : (
<div className="flex max-h-full min-h-[200px] w-full flex-col gap-1 lg:w-2/3">
<div className="flex flex-row items-end justify-between">
<span className="h-fit px-1 text-start text-sm font-medium">
Evaluation Prompt
</span>
<div className="flex max-h-full min-h-48 w-full flex-col gap-1 lg:w-2/3">
<div className="flex flex-row items-center justify-between py-0 text-sm font-medium capitalize">
<div className="flex flex-row items-center gap-2">
Evaluation Prompt Preview
<Skeleton className="h-[25px] w-[63px]" />
</div>
<div className="flex justify-end">
{mappingControlButtons}
</div>
@@ -44,7 +44,8 @@ export function useExtractVariables({
const previousMappingRef = useRef<string>("");
// Create a stable string representation of the current mapping for comparison
const currentMappingString = JSON.stringify(variableMapping);
const currentMappingString =
variables.length > 0 ? JSON.stringify(variableMapping) : "";
// Create a stable reference to the trace ID
const traceId = trace?.id;
@@ -59,10 +60,14 @@ export function useExtractVariables({
useEffect(() => {
// Return early conditions
if (isLoading || !variables.length) {
setExtractedVariables(
variables.map((variable) => ({ variable, value: "n/a" })),
);
if (isLoading) {
setExtractedVariables([]);
return;
}
// If no variables, only update if current state is not empty
if (!Boolean(variables.length)) {
setExtractedVariables((prev) => (prev.length === 0 ? prev : []));
return;
}
@@ -638,7 +638,7 @@ export const CreateExperimentsForm = ({
<span>
Given current prompt, dataset item input must
contain at least one of these first-level JSON
keys, mapped to a string value:
keys:
</span>
<ul className="my-2 ml-2 list-inside list-disc">
{expectedColumns.map((col) => (
@@ -646,8 +646,9 @@ export const CreateExperimentsForm = ({
))}
</ul>
<span>
These will be used as the input to your
prompt.
Variables (like {"{{variable}}"}) should be mapped to string values.
Placeholders should be mapped to arrays of message objects.
These will be used as the input to your prompt.
</span>
</div>
) : (
@@ -721,7 +722,7 @@ export const CreateExperimentsForm = ({
<Loader2 className="h-3 w-3 animate-spin" />
</CardTitle>
<CardDescription className="text-foreground">
Checking dataset items against prompt variables
Checking dataset items against prompt variables and placeholders
</CardDescription>
</CardHeader>
</Card>
@@ -747,7 +748,7 @@ export const CreateExperimentsForm = ({
<CircleCheck className="h-4 w-4" />
</CardTitle>
<div className="text-sm">
Matches between dataset items and prompt variables
Matches between dataset items and prompt variables/placeholders
<ul className="my-2 ml-2 list-inside list-disc">
{Object.entries(
validationResult.data.variablesMap ?? {},
@@ -760,7 +761,7 @@ export const CreateExperimentsForm = ({
</li>
))}
</ul>
Items missing all prompt variables will be excluded from
Items missing all required variables and placeholders will be excluded from
the experiment.
</div>
</CardHeader>
@@ -1,8 +1,7 @@
import { useMemo } from "react";
import { api } from "@/src/utils/api";
import { type UseFormReturn } from "react-hook-form";
import { extractVariables } from "@langfuse/shared";
import { PromptType } from "@/src/features/prompts/server/utils/validation";
import { extractVariables, PromptType, extractPlaceholderNames, type PromptMessage } from "@langfuse/shared";
type ExperimentPromptDataProps = {
projectId: string;
@@ -23,11 +22,18 @@ export function useExperimentPromptData({
const prompt = promptMeta.data?.find((p) => p.id === promptId);
if (!prompt) return [];
return extractVariables(
const extractedVariables = extractVariables(
prompt.type === PromptType.Text
? (prompt?.prompt?.toString() ?? "")
: JSON.stringify(prompt?.prompt),
);
const promptMessages = prompt?.type === PromptType.Chat && Array.isArray(prompt.prompt)
? prompt.prompt
: [];
const placeholderNames = extractPlaceholderNames(promptMessages as PromptMessage[]);
return [...extractedVariables, ...placeholderNames];
}, [promptId, promptMeta.data]);
const promptsByName = useMemo(
+14 -5
View File
@@ -2,23 +2,25 @@ import { z } from "zod/v4";
import { randomUUID } from "crypto";
import {
type ExperimentMetadata,
ExperimentCreateQueue,
QueueJobs,
QueueName,
redis,
ZodModelConfig,
ExperimentCreateQueue,
} from "@langfuse/shared/src/server";
import {
createTRPCRouter,
protectedProjectProcedure,
} from "@/src/server/api/trpc";
import { PromptType } from "@/src/features/prompts/server/utils/validation";
import {
type DatasetItem,
DatasetStatus,
extractVariables,
datasetItemMatchesVariable,
UnauthorizedError,
PromptType,
extractPlaceholderNames,
type PromptMessage,
} from "@langfuse/shared";
import { throwIfNoProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
@@ -97,10 +99,17 @@ export const experimentsRouter = createTRPCRouter({
: JSON.stringify(prompt.prompt),
);
if (!Boolean(extractedVariables.length)) {
const promptMessages = prompt?.type === PromptType.Chat && Array.isArray(prompt.prompt)
? prompt.prompt
: [];
const placeholderNames = extractPlaceholderNames(promptMessages as PromptMessage[]);
const allVariables = [...extractedVariables, ...placeholderNames];
if (!Boolean(allVariables.length)) {
return {
isValid: false,
message: "Selected prompt has no variables.",
message: "Selected prompt has no variables or placeholders.",
};
}
@@ -121,7 +130,7 @@ export const experimentsRouter = createTRPCRouter({
const variablesMap = validateDatasetItems(
datasetItems,
extractedVariables,
allVariables,
);
if (!Boolean(Object.keys(variablesMap).length)) {
@@ -310,7 +310,11 @@ export class OtelIngestionProcessor {
scopeSpan.scope?.name.startsWith("langfuse-sdk") ?? false;
const scopeAttributes = this.extractScopeAttributes(scopeSpan);
this.validatePublicKey(isLangfuseSDKSpans, scopeAttributes);
this.validatePublicKey(
isLangfuseSDKSpans,
scopeAttributes,
resourceAttributes,
);
if (isLangfuseSDKSpans) {
recordIncrement("langfuse.otel.ingestion.langfuse_sdk_batch", 1);
@@ -598,11 +602,15 @@ export class OtelIngestionProcessor {
private validatePublicKey(
isLangfuseSDKSpans: boolean,
scopeAttributes: Record<string, unknown>,
resourceAttributes: Record<string, unknown>,
): void {
if (
isLangfuseSDKSpans &&
(!this.publicKey ||
(scopeAttributes["public_key"] as unknown as string) !== this.publicKey)
(scopeAttributes["public_key"] as unknown as string) !==
this.publicKey) &&
(resourceAttributes["telemetry.sdk.language"] as unknown as string) ===
"python" // Only Python has multi project setups. Node OTEL does not allow setting scope.attributes, thus skipping the check for node
) {
throw new ForbiddenError(
`Langfuse OTEL SDK span has different public key '${scopeAttributes["public_key"]}' than used for authentication '${this.publicKey}'. Discarding span.`,
@@ -620,6 +628,8 @@ export class OtelIngestionProcessor {
LangfuseOtelSpanAttributes.TRACE_SESSION_ID,
LangfuseOtelSpanAttributes.TRACE_PUBLIC,
LangfuseOtelSpanAttributes.TRACE_TAGS,
LangfuseOtelSpanAttributes.TRACE_COMPAT_USER_ID,
LangfuseOtelSpanAttributes.TRACE_COMPAT_SESSION_ID,
`${LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.langfuse_user_id`,
`${LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.langfuse_session_id`,
`${LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.langfuse_tags`,
@@ -33,4 +33,9 @@ export enum LangfuseOtelSpanAttributes {
// Internal
AS_ROOT = "langfuse.internal.as_root",
// Compatibility - Map properties that were documented in https://langfuse.com/docs/opentelemetry/get-started#property-mapping,
// but have a new assignment
TRACE_COMPAT_USER_ID = "langfuse.user.id",
TRACE_COMPAT_SESSION_ID = "langfuse.session.id",
}
@@ -7,12 +7,11 @@ import { createEmptyMessage } from "@/src/components/ChatMessages/utils/createEm
import { Button } from "@/src/components/ui/button";
import usePlaygroundCache from "@/src/features/playground/page/hooks/usePlaygroundCache";
import {
type PlaygroundTool,
type PlaygroundCache,
type PlaygroundSchema,
type PlaygroundTool,
} from "@/src/features/playground/page/types";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { PromptType } from "@/src/features/prompts/server/utils/validation";
import useProjectIdFromURL from "@/src/hooks/useProjectIdFromURL";
import {
ChatMessageRole,
@@ -28,6 +27,10 @@ import {
type ChatMessage,
OpenAIResponseFormatSchema,
type Prisma,
PlaceholderMessageSchema,
type PlaceholderMessage,
isPlaceholder,
PromptType,
} from "@langfuse/shared";
import { api } from "@/src/utils/api";
import { cn } from "@/src/utils/tailwind";
@@ -119,7 +122,7 @@ export const JumpToPlaygroundButton: React.FC<JumpToPlaygroundButtonProps> = (
title={
isAvailable
? "Test in LLM playground"
: "Test in LLM playground is not available since messages are not in valid ChatML format or tool calls have been used. If you think this is not correct, please open a Github issue."
: "Test in LLM playground is not available since messages are not in valid ChatML format or tool calls have been used. If you think this is not correct, please open a GitHub issue."
}
onClick={handleClick}
asChild
@@ -138,34 +141,38 @@ export const JumpToPlaygroundButton: React.FC<JumpToPlaygroundButtonProps> = (
};
const ParsedChatMessageListSchema = z.array(
z.object({
role: z.enum(ChatMessageRole),
content: z.union([
z.string(),
z
.array(
z
.object({
text: z.string(),
})
.transform((v) => v.text),
)
.transform((v) => v.join("")),
z.union([z.null(), z.undefined()]).transform((_) => ""),
z.any().transform((v) => JSON.stringify(v, null, 2)),
]),
tool_calls: z
.union([z.array(LLMToolCallSchema), z.array(OpenAIToolCallSchema)])
.optional(),
tool_call_id: z.string().optional(),
additional_kwargs: z
.object({
tool_calls: z
.union([z.array(LLMToolCallSchema), z.array(OpenAIToolCallSchema)])
.optional(),
})
.optional(),
}),
z.union([
// Regular chat message
z.object({
role: z.enum(ChatMessageRole),
content: z.union([
z.string(),
z
.array(
z
.object({
text: z.string(),
})
.transform((v) => v.text),
)
.transform((v) => v.join("")),
z.union([z.null(), z.undefined()]).transform((_) => ""),
z.any().transform((v) => JSON.stringify(v, null, 2)),
]),
tool_calls: z
.union([z.array(LLMToolCallSchema), z.array(OpenAIToolCallSchema)])
.optional(),
tool_call_id: z.string().optional(),
additional_kwargs: z
.object({
tool_calls: z
.union([z.array(LLMToolCallSchema), z.array(OpenAIToolCallSchema)])
.optional(),
})
.optional(),
}),
PlaceholderMessageSchema,
])
);
// Langchain integration has the tool definition in a tool message
@@ -173,6 +180,9 @@ const ParsedChatMessageListSchema = z.array(
const isLangchainToolDefinitionMessage = (
message: z.infer<typeof ParsedChatMessageListSchema>[0],
): message is { content: string; role: ChatMessageRole } => {
if (!("content" in message) || typeof message.content !== "string") {
return false;
}
try {
return OpenAIToolSchema.safeParse(JSON.parse(message.content)).success;
} catch {
@@ -182,15 +192,22 @@ const isLangchainToolDefinitionMessage = (
const transformToPlaygroundMessage = (
message: z.infer<typeof ParsedChatMessageListSchema>[0],
): ChatMessage => {
const { role, content } = message;
): ChatMessage | PlaceholderMessage => {
// Return placeholder messages as-is
if (isPlaceholder(message)) {
return message;
}
// Handle regular chat messages - remove the placeholder type
const regularMessage = message as Exclude<typeof message, PlaceholderMessage>;
const { role, content } = regularMessage;
if (
message.role === "assistant" &&
(message.tool_calls || message.additional_kwargs?.tool_calls)
regularMessage.role === "assistant" &&
(regularMessage.tool_calls || regularMessage.additional_kwargs?.tool_calls)
) {
const toolCalls =
message.tool_calls ?? message.additional_kwargs?.tool_calls ?? [];
regularMessage.tool_calls ?? regularMessage.additional_kwargs?.tool_calls ?? [];
const playgroundMessage: ChatMessage = {
role: ChatMessageRole.Assistant,
@@ -210,12 +227,12 @@ const transformToPlaygroundMessage = (
};
return playgroundMessage;
} else if (message.role === "tool") {
} else if (regularMessage.role === "tool") {
const playgroundMessage: ChatMessage = {
role: ChatMessageRole.Tool,
content,
type: ChatMessageType.ToolResult,
toolCallId: message.tool_call_id ?? "",
toolCallId: regularMessage.tool_call_id ?? "",
};
return playgroundMessage;
@@ -0,0 +1,82 @@
import { CheckCircle2, Circle, TrashIcon } from "lucide-react";
import { Button } from "@/src/components/ui/button";
import { CodeMirrorEditor } from "@/src/components/editor";
import { useState, useCallback } from "react";
import { type ChatMessage, PromptChatMessageListSchema } from "@langfuse/shared";
import { usePlaygroundContext } from "../context";
import { type PlaceholderMessageFillIn } from "../types";
import { useNamingConflicts } from "../hooks/useNamingConflicts";
export const MessagePlaceholderComponent: React.FC<{
messagePlaceholder: PlaceholderMessageFillIn;
}> = ({ messagePlaceholder }) => {
const { updateMessagePlaceholderValue, deleteMessagePlaceholder, promptVariables, messagePlaceholders } =
usePlaygroundContext();
const { name, value, isUsed } = messagePlaceholder;
const [error, setError] = useState<string | null>(null);
const { isPlaceholderConflicting } = useNamingConflicts(promptVariables, messagePlaceholders);
const hasConflict = isPlaceholderConflicting(name);
const handleInputChange = useCallback((jsonString: string) => {
try {
const parsed = jsonString.trim() === "" ? [] : JSON.parse(jsonString);
const result = PromptChatMessageListSchema.safeParse(parsed);
if (result.success) {
updateMessagePlaceholderValue(name, result.data as ChatMessage[]);
setError(null);
} else {
setError(result.error.issues[0]?.message || "Invalid chat message format, ensure it has role and content keys.");
}
} catch {
setError("Invalid JSON format");
}
}, [name, updateMessagePlaceholderValue]);
const UsedIcon = isUsed ? CheckCircle2 : Circle;
const iconColor = isUsed ? "green" : "grey";
return (
<div className="p-1">
<div className="mb-1 flex flex-row items-center">
<span className="flex flex-1 flex-row space-x-2 text-xs">
<UsedIcon size={16} color={iconColor} />
<p className={`min-w-[90px] truncate font-mono ${hasConflict ? 'text-red-500' : ''}`} title={name}>
{name ? name : "Unnamed placeholder"}
</p>
</span>
<Button
variant="ghost"
size="icon"
title="Delete placeholder"
disabled={isUsed}
onClick={() => deleteMessagePlaceholder(name)}
className="p-0"
>
{!isUsed && <TrashIcon size={16} />}
</Button>
</div>
<CodeMirrorEditor
value={value.length === 0 ? `[\n {\n "role": "",\n "content": ""\n }\n]` : JSON.stringify(value, null, 2)}
onChange={handleInputChange}
mode="json"
minHeight="none"
className={`max-h-[15rem] w-full resize-y p-1 font-mono text-xs focus:outline-none ${hasConflict ? 'border border-red-500' : ''}`}
editable={true}
lineNumbers={false}
placeholder={`[\n {\n "role": "user",\n "content": "Hello!"\n },\n {\n "role": "assistant",\n "content": "Hi there!"\n }\n]`}
/>
{hasConflict && (
<p className="mt-1 text-xs text-red-500">
Placeholder name conflicts with variable. Names must be unique.
</p>
)}
{error && (
<p className="mt-1 text-xs text-red-500">{error}</p>
)}
</div>
);
};
@@ -0,0 +1,38 @@
import { Divider } from "@tremor/react";
import { usePlaygroundContext } from "../context";
import { MessagePlaceholderComponent } from "./MessagePlaceholderComponent";
export const MessagePlaceholders = () => {
const { messagePlaceholders } = usePlaygroundContext();
return (
<div className="flex h-full flex-col">
<p className="font-semibold">Message Placeholders</p>
{messagePlaceholders.length === 0 ? (
<div className="mt-4 text-xs">
<p className="mb-2">No message placeholders defined.</p>
<p>Placeholders can be used to e.g. inject message histories into prompts.</p>
</div>
) : (
<div className="h-full overflow-auto">
{messagePlaceholders
.slice()
.sort((a, b) => {
if (a.isUsed && !b.isUsed) return -1;
if (!a.isUsed && b.isUsed) return 1;
return a.name.localeCompare(b.name);
})
.map((placeholder, index) => (
<div key={placeholder.name}>
<MessagePlaceholderComponent messagePlaceholder={placeholder} />
{index !== messagePlaceholders.length - 1 && (
<Divider className="my-2 text-muted-foreground" />
)}
</div>
))}
</div>
)}
</div>
);
};
@@ -4,13 +4,16 @@ import { type PromptVariable } from "@langfuse/shared";
import { CodeMirrorEditor } from "@/src/components/editor";
import { usePlaygroundContext } from "../context";
import { useNamingConflicts } from "../hooks/useNamingConflicts";
export const PromptVariableComponent: React.FC<{
promptVariable: PromptVariable;
}> = ({ promptVariable }) => {
const { updatePromptVariableValue, deletePromptVariable } =
const { updatePromptVariableValue, deletePromptVariable, promptVariables, messagePlaceholders } =
usePlaygroundContext();
const { name, value, isUsed } = promptVariable;
const { isVariableConflicting } = useNamingConflicts(promptVariables, messagePlaceholders);
const hasConflict = isVariableConflicting(name);
const handleInputChange = (value: string) => {
updatePromptVariableValue(name, value);
@@ -32,7 +35,7 @@ export const PromptVariableComponent: React.FC<{
<div className="mb-1 flex flex-row items-center">
<span className="flex flex-1 flex-row space-x-2 text-xs">
<p title={isUsedTooltip}>{isUsedIcon}</p>
<p className="min-w-[90px] truncate font-mono" title={name}>
<p className={`min-w-[90px] truncate font-mono ${hasConflict ? 'text-red-500' : ''}`} title={name}>
{name}
</p>
</span>
@@ -53,11 +56,17 @@ export const PromptVariableComponent: React.FC<{
onChange={(e) => handleInputChange(e)}
mode="prompt"
minHeight="none"
className="max-h-[10rem] w-full resize-y p-1 font-mono text-xs focus:outline-none"
className={`max-h-[10rem] w-full resize-y p-1 font-mono text-xs focus:outline-none ${hasConflict ? 'border border-red-500' : ''}`}
editable={true}
lineNumbers={false}
placeholder={name}
/>
{hasConflict && (
<p className="mt-1 text-xs text-red-500">
Variable name conflicts with placeholder. Names must be unique.
</p>
)}
</div>
);
};
@@ -20,11 +20,11 @@ import {
import { usePlaygroundContext } from "@/src/features/playground/page/context";
import usePlaygroundCache from "@/src/features/playground/page/hooks/usePlaygroundCache";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { PromptType } from "@/src/features/prompts/server/utils/validation";
import useProjectIdFromURL from "@/src/hooks/useProjectIdFromURL";
import { api } from "@/src/utils/api";
import { cn } from "@/src/utils/tailwind";
import DocPopup from "@/src/components/layouts/doc-popup";
import { PromptType } from "@langfuse/shared";
export const SaveToPromptButton: React.FC = () => {
const [selectedPromptId, setSelectedPromptId] = useState("");
@@ -18,6 +18,7 @@ import {
ChatMessageRole,
extractVariables,
type ChatMessageWithId,
type ChatMessageWithIdNoPlaceholders,
type PromptVariable,
ToolCallResponseSchema,
type UIModelParams,
@@ -25,6 +26,9 @@ import {
type LLMToolDefinition,
type LLMToolCall,
ChatMessageType,
type ChatMessage,
compileChatMessagesWithIds,
type MessagePlaceholderValues,
} from "@langfuse/shared";
import type { MessagesContext } from "@/src/components/ChatMessages/types";
@@ -33,14 +37,20 @@ import { env } from "@/src/env.mjs";
import {
type PlaygroundSchema,
type PlaygroundTool,
type PlaceholderMessageFillIn,
} from "@/src/features/playground/page/types";
import { getFinalModelParams } from "@/src/utils/getFinalModelParams";
type PlaygroundContextType = {
promptVariables: PromptVariable[];
updatePromptVariableValue: (variable: string, value: string) => void;
deletePromptVariable: (variable: string) => void;
messagePlaceholders: PlaceholderMessageFillIn[];
updateMessagePlaceholderValue: (name: string, value: ChatMessage[]) => void;
deleteMessagePlaceholder: (name: string) => void;
tools: PlaygroundTool[];
setTools: React.Dispatch<React.SetStateAction<PlaygroundTool[]>>;
@@ -77,6 +87,7 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
const projectId = useProjectIdFromURL();
const { playgroundCache, setPlaygroundCache } = usePlaygroundCache();
const [promptVariables, setPromptVariables] = useState<PromptVariable[]>([]);
const [messagePlaceholders, setMessagePlaceholders] = useState<PlaceholderMessageFillIn[]>([]);
const [output, setOutput] = useState("");
const [outputToolCalls, setOutputToolCalls] = useState<LLMToolCall[]>([]);
const [outputJson, setOutputJson] = useState("");
@@ -160,7 +171,7 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
}, [playgroundCache, setModelParams]);
const updatePromptVariables = useCallback(() => {
const messageContents = messages.map((m) => m.content).join("\n");
const messageContents = messages.map((m) => ('content' in m ? m.content : m.name)).join("\n");
const variables = extractVariables(messageContents)
.map((v) => v.trim())
.filter(Boolean);
@@ -219,6 +230,10 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
]);
return toolCallMessage;
} else if (message.type === ChatMessageType.Placeholder) {
const placeholderMessage = { ...message, id: uuidv4() } as ChatMessageWithId;
setMessages((prev) => [...prev, placeholderMessage]);
return placeholderMessage;
} else {
const newMessage = createEmptyMessage(message);
setMessages((prev) => [...prev, newMessage]);
@@ -264,7 +279,7 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
setOutputJson("");
setOutputToolCalls([]);
const finalMessages = getFinalMessages(promptVariables, messages);
const finalMessages = getFinalMessages(promptVariables, messages, messagePlaceholders);
const leftOverVariables = extractVariables(
finalMessages.map((m) => m.content).join("\n"),
);
@@ -372,6 +387,7 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
messages,
modelParams,
promptVariables,
messagePlaceholders,
tools,
capture,
setPlaygroundCache,
@@ -395,12 +411,57 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
setPromptVariables((prev) => prev.filter((v) => v.name !== variable));
}, []);
const updateMessagePlaceholderValue = useCallback((name: string, value: ChatMessage[]) => {
setMessagePlaceholders((prev) =>
prev.map((p) => (p.name === name ? { ...p, value } : p)),
);
}, []);
const deleteMessagePlaceholder = useCallback((name: string) => {
setMessagePlaceholders((prev) => prev.filter((p) => p.name !== name));
}, []);
const updateMessagePlaceholders = useCallback(() => {
const placeholderNames = messages
.filter((msg): msg is ChatMessageWithId & { type: ChatMessageType.Placeholder; name: string } =>
msg.type === ChatMessageType.Placeholder
)
.map((msg) => msg.name);
setMessagePlaceholders((prev) => {
// Set isUsed flag for existing placeholders and remove unused ones
const next = prev.reduce<PlaceholderMessageFillIn[]>((updatedPlaceholders, p) => {
const isUsed = placeholderNames.includes(p.name);
// Remove unused placeholders
if (!isUsed && p.value.length === 0) {
return updatedPlaceholders;
}
updatedPlaceholders.push({ ...p, isUsed });
return updatedPlaceholders;
}, []);
// Add new placeholders
for (const name of placeholderNames) {
if (!next.some((p) => p.name === name)) {
next.push({ name, value: [], isUsed: true });
}
}
return next;
});
}, [messages]);
useEffect(updateMessagePlaceholders, [messages, updateMessagePlaceholders]);
return (
<PlaygroundContext.Provider
value={{
promptVariables,
updatePromptVariableValue,
deletePromptVariable,
messagePlaceholders,
updateMessagePlaceholderValue,
deleteMessagePlaceholder,
tools,
setTools,
@@ -437,7 +498,7 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
async function getChatCompletionWithTools(
projectId: string | undefined,
messages: ChatMessageWithId[],
messages: ChatMessageWithIdNoPlaceholders[],
modelParams: UIModelParams,
tools: unknown[],
streaming: boolean = false,
@@ -607,7 +668,8 @@ async function getChatCompletionNonStreaming(
function getFinalMessages(
promptVariables: PromptVariable[],
messages: ChatMessageWithId[],
) {
messagePlaceholders: PlaceholderMessageFillIn[],
): ChatMessageWithIdNoPlaceholders[] {
const missingVariables = promptVariables.filter((v) => !v.value && v.isUsed);
if (missingVariables.length > 0) {
throw new Error(
@@ -617,24 +679,44 @@ function getFinalMessages(
);
}
// Dynamically replace variables in the prompt
const finalMessages = messages
.filter(
(m) =>
m.content.length > 0 || ("toolCalls" in m && m.toolCalls.length > 0),
)
.map((m) => {
let content = m.content;
for (const variable of promptVariables) {
content = content.replace(
new RegExp(`{{\\s*${variable.name}\\s*}}`, "g"),
variable.value,
);
}
const missingPlaceholders = messagePlaceholders.filter(
(p) => p.value.length === 0 && p.isUsed,
);
if (missingPlaceholders.length > 0) {
throw new Error(
`Please set values for the following message placeholders: ${missingPlaceholders
.map((p) => p.name)
.join(", ")}`,
);
}
return { ...m, content };
});
return finalMessages;
const placeholderValues: MessagePlaceholderValues = messagePlaceholders.reduce(
(placeholderMap, p) => {
placeholderMap[p.name] = p.value;
return placeholderMap;
},
{} as MessagePlaceholderValues,
);
const textVariables = promptVariables.reduce(
(variableMap, v) => {
variableMap[v.name] = v.value;
return variableMap;
},
{} as Record<string, string>,
);
const compiledMessages = compileChatMessagesWithIds(
messages,
placeholderValues,
textVariables,
);
// Filter empty messages (except tool calls), e.g. if placeholder value was empty
return compiledMessages.filter(
(m) =>
m.content.length > 0 || ("toolCalls" in m && m.toolCalls && m.toolCalls.length > 0),
);
}
function getOutputJson(
@@ -0,0 +1,43 @@
import { useMemo } from "react";
import { type PromptVariable } from "@langfuse/shared";
import { type PlaceholderMessageFillIn } from "../types";
export interface NamingConflictInfo {
hasConflicts: boolean;
conflictingNames: string[];
isVariableConflicting: (variableName: string) => boolean;
isPlaceholderConflicting: (placeholderName: string) => boolean;
}
/**
* Detects naming conflicts between variables and placeholders in the playground.
* Returns names of conflicting variables and placeholders.
*/
export const useNamingConflicts = (
variables: PromptVariable[],
placeholders: PlaceholderMessageFillIn[]
): NamingConflictInfo => {
return useMemo(() => {
const variableNames = variables.map(v => v.name);
const placeholderNames = placeholders.map(p => p.name);
const conflictingNames = variableNames.filter(name =>
placeholderNames.includes(name)
);
const hasConflicts = conflictingNames.length > 0;
const isVariableConflicting = (variableName: string) =>
conflictingNames.includes(variableName);
const isPlaceholderConflicting = (placeholderName: string) =>
conflictingNames.includes(placeholderName);
return {
hasConflicts,
conflictingNames,
isVariableConflicting,
isPlaceholderConflicting,
};
}, [variables, placeholders]);
};
@@ -1,6 +1,7 @@
import { ModelParameters } from "@/src/components/ModelParameters";
import { usePlaygroundContext } from "./context";
import { Variables } from "./components/Variables";
import { MessagePlaceholders } from "./components/MessagePlaceholders";
import { Messages } from "./components/Messages";
import { PlaygroundTools } from "./components/PlaygroundTools";
import { StructuredOutputSchemaSection } from "./components/StructuredOutputSchemaSection";
@@ -25,7 +26,10 @@ export default function Playground() {
<StructuredOutputSchemaSection />
</div>
<div className="flex-grow overflow-y-auto">
<Variables />
<div className="space-y-6">
<Variables />
<MessagePlaceholders />
</div>
</div>
</div>
</div>
+14 -6
View File
@@ -1,11 +1,12 @@
import {
type PromptVariable,
type ChatMessage,
type UIModelParams,
type LlmSchema,
type LLMToolDefinition,
type LlmTool,
type LLMJSONSchema,
type LlmSchema,
type LlmTool,
type LLMToolDefinition,
type PlaceholderMessage,
type PromptVariable,
type UIModelParams,
} from "@langfuse/shared";
export type PlaygroundTool = LLMToolDefinition & {
@@ -21,12 +22,19 @@ export type PlaygroundSchema = {
existingLlmSchema?: LlmSchema;
};
export type PlaceholderMessageFillIn = {
name: string;
value: ChatMessage[];
isUsed: boolean;
};
export type PlaygroundCache = {
messages: ChatMessage[];
messages: (ChatMessage | PlaceholderMessage)[];
modelParams?: Partial<UIModelParams> &
Pick<UIModelParams, "provider" | "model">;
output?: string | null;
promptVariables?: PromptVariable[];
// TODO: also cache placeholders?
tools?: PlaygroundTool[];
structuredOutputSchema?: PlaygroundSchema | null;
} | null;
@@ -9,6 +9,7 @@ import {
import { decrypt, encrypt } from "@langfuse/shared/encryption";
import { posthogIntegrationFormSchema } from "@/src/features/posthog-integration/types";
import { TRPCError } from "@trpc/server";
import { env } from "@/src/env.mjs";
export const posthogIntegrationRouter = createTRPCRouter({
get: protectedProjectProcedure
@@ -47,44 +48,51 @@ export const posthogIntegrationRouter = createTRPCRouter({
update: protectedProjectProcedure
.input(posthogIntegrationFormSchema.extend({ projectId: z.string() }))
.mutation(async ({ input, ctx }) => {
try {
throwIfNoProjectAccess({
session: ctx.session,
projectId: input.projectId,
scope: "integrations:CRUD",
});
await auditLog({
session: ctx.session,
action: "update",
resourceType: "posthogIntegration",
resourceId: input.projectId,
});
const { posthogProjectApiKey, ...config } = input;
const encryptedPosthogApiKey = encrypt(posthogProjectApiKey);
await ctx.prisma.posthogIntegration.upsert({
where: {
projectId: input.projectId,
},
create: {
projectId: input.projectId,
posthogHostName: config.posthogHostname,
encryptedPosthogApiKey,
enabled: config.enabled,
},
update: {
encryptedPosthogApiKey,
posthogHostName: config.posthogHostname,
enabled: config.enabled,
},
});
} catch (e) {
console.log("posthog integration update", e);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
});
throwIfNoProjectAccess({
session: ctx.session,
projectId: input.projectId,
scope: "integrations:CRUD",
});
if (!env.ENCRYPTION_KEY) {
if (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Internal server error",
});
} else {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Missing environment variable: `ENCRYPTION_KEY`. Please consult our docs: https://langfuse.com/self-hosting",
});
}
}
await auditLog({
session: ctx.session,
action: "update",
resourceType: "posthogIntegration",
resourceId: input.projectId,
});
const { posthogProjectApiKey, ...config } = input;
const encryptedPosthogApiKey = encrypt(posthogProjectApiKey);
await ctx.prisma.posthogIntegration.upsert({
where: {
projectId: input.projectId,
},
create: {
projectId: input.projectId,
posthogHostName: config.posthogHostname,
encryptedPosthogApiKey,
enabled: config.enabled,
},
update: {
encryptedPosthogApiKey,
posthogHostName: config.posthogHostname,
enabled: config.enabled,
},
});
}),
delete: protectedProjectProcedure
.input(z.object({ projectId: z.string() }))
@@ -50,17 +50,31 @@ export const PromptChatMessages: React.FC<PromptChatMessagesProps> = ({
}
setMessages(
parsedMessages.data.map((message) => ({
...message,
id: uuidv4(),
type: ChatMessageType.PublicAPICreated,
})),
parsedMessages.data.map((message) => {
const id = uuidv4();
// TODO: clean up - Placeholder messages could also be API created..
if ('type' in message && message.type === ChatMessageType.Placeholder) {
return {
...message,
id,
type: ChatMessageType.Placeholder,
} as ChatMessageWithId;
} else {
return {
...message,
id,
type: ChatMessageType.PublicAPICreated,
} as ChatMessageWithId;
}
}),
);
const customRoles = parsedMessages.data.reduce((acc, message) => {
const { role } = message;
if (ChatMessageDefaultRoleSchema.safeParse(role).error) {
acc.add(role);
if ('role' in message) {
const { role } = message;
if (ChatMessageDefaultRoleSchema.safeParse(role).error) {
acc.add(role);
}
}
return acc;
}, new Set<string>());
@@ -20,15 +20,14 @@ import {
TabsTrigger,
} from "@/src/components/ui/tabs";
import { Textarea } from "@/src/components/ui/textarea";
import {
type CreatePromptTRPCType,
PromptType,
} from "@/src/features/prompts/server/utils/validation";
import useProjectIdFromURL from "@/src/hooks/useProjectIdFromURL";
import { api } from "@/src/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import {
type CreatePromptTRPCType,
PRODUCTION_LABEL,
type Prompt,
PromptType,
extractVariables,
getIsCharOrUnderscore,
} from "@langfuse/shared";
@@ -46,7 +45,6 @@ import { ArrowTopRightIcon } from "@radix-ui/react-icons";
import { PromptVariableListPreview } from "@/src/features/prompts/components/PromptVariableListPreview";
import { CodeMirrorEditor } from "@/src/components/editor/CodeMirrorEditor";
import { PromptLinkingEditor } from "@/src/components/editor/PromptLinkingEditor";
import { PRODUCTION_LABEL } from "@/src/features/prompts/constants";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import usePlaygroundCache from "@/src/features/playground/page/hooks/usePlaygroundCache";
import { useQueryParam } from "use-query-params";
@@ -215,7 +213,16 @@ export const NewPromptForm: React.FC<NewPromptFormProps> = (props) => {
<FormItem>
<FormLabel>Name</FormLabel>
<FormDescription>
Use slashes &apos;/&apos; in prompt names to organize them into <a target="_blank" rel="noopener noreferrer" href="https://langfuse.com/docs/prompts/get-started#prompt-folders-for-organization"><i>folders</i></a>.
Use slashes &apos;/&apos; in prompt names to organize them
into{" "}
<a
target="_blank"
rel="noopener noreferrer"
href="https://langfuse.com/docs/prompts/get-started#prompt-folders-for-organization"
>
<i>folders</i>
</a>
.
</FormDescription>
<FormControl>
<Input placeholder="Name your prompt" {...field} />
@@ -1,11 +1,13 @@
import { z } from "zod/v4";
import { PromptType } from "@/src/features/prompts/server/utils/validation";
import {
ChatMessageType,
PlaceholderMessageSchema,
PromptChatMessageListSchema,
TextPromptSchema,
PromptNameSchema,
TextPromptContentSchema,
COMMIT_MESSAGE_MAX_LENGTH,
PromptType,
} from "@langfuse/shared";
import { COMMIT_MESSAGE_MAX_LENGTH } from "@/src/features/prompts/constants";
const NewPromptBaseSchema = z.object({
name: PromptNameSchema,
@@ -23,17 +25,30 @@ const NewPromptBaseSchema = z.object({
const NewChatPromptSchema = NewPromptBaseSchema.extend({
type: z.literal(PromptType.Chat),
chatPrompt: PromptChatMessageListSchema.refine(
(messages) => messages.every((message) => message.content.length > 0),
"Enter a chat message or remove the empty message",
),
chatPrompt: z.array(z.any())
.refine(
(messages: Array<{ type?: ChatMessageType; content?: string }>) =>
messages.every((message) => {
const isPlaceholder = message?.type === ChatMessageType.Placeholder;
return !isPlaceholder || PlaceholderMessageSchema.safeParse(message).success;
}),
"Placeholder name must start with a letter and contain only alphanumeric characters and underscores"
)
.refine(
(messages: Array<{ type?: ChatMessageType; content?: string }>) =>
messages.every((message) => {
const isPlaceholder = message?.type === ChatMessageType.Placeholder;
return isPlaceholder || Boolean(message?.content?.trim()?.length);
}),
"Enter a chat message or remove the empty message"
),
textPrompt: z.string(),
});
const NewTextPromptSchema = NewPromptBaseSchema.extend({
type: z.literal(PromptType.Text),
chatPrompt: z.array(z.any()),
textPrompt: TextPromptSchema,
textPrompt: TextPromptContentSchema,
});
export const NewPromptFormSchema = z.discriminatedUnion("type", [
@@ -13,7 +13,6 @@ import {
import Header from "@/src/components/layouts/header";
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
import { useHasEntitlement } from "@/src/features/entitlements/hooks";
import { PromptLabelSchema } from "@/src/features/prompts/server/utils/validation";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod/v4";
import { XIcon, Check, ChevronsUpDown } from "lucide-react";
@@ -31,11 +30,13 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/src/components/ui/popover";
import { StatusBadge } from "@/src/components/layouts/status-badge";
import {
LATEST_PROMPT_LABEL,
PRODUCTION_LABEL,
} from "@/src/features/prompts/constants";
import { StatusBadge } from "@/src/components/layouts/status-badge";
PromptLabelSchema,
} from "@langfuse/shared";
const AddLabelFormSchema = z.object({
label: PromptLabelSchema,
@@ -12,9 +12,9 @@ import {
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";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { isReservedPromptLabel } from "@/src/features/prompts/utils";
import { PromptLabelSchema } from "@langfuse/shared";
const AddLabelFormSchema = z.object({
newLabel: PromptLabelSchema.refine(
@@ -15,10 +15,9 @@ import {
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
import useProjectIdFromURL from "@/src/hooks/useProjectIdFromURL";
import { api } from "@/src/utils/api";
import { type Prompt } from "@langfuse/shared";
import { PRODUCTION_LABEL, type Prompt } from "@langfuse/shared";
import { AddLabelForm } from "./AddLabelForm";
import { LabelCommandItem } from "./LabelCommandItem";
import { PRODUCTION_LABEL } from "@/src/features/prompts/constants";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { isReservedPromptLabel } from "@/src/features/prompts/utils";
import { StatusBadge } from "@/src/components/layouts/status-badge";
@@ -47,6 +46,9 @@ export function SetPromptVersionLabels({
const [selectedLabels, setSelectedLabels] = useState<string[]>([]);
const [labels, setLabels] = useState<string[]>([]);
const [isAddingLabel, setIsAddingLabel] = useState(false);
const labelsChanged =
JSON.stringify([...selectedLabels].sort()) !==
JSON.stringify([...prompt.labels].sort());
const customLabelScrollRef = useRef<HTMLDivElement | null>(null);
const usedLabelsInProject = api.prompts.allLabels.useQuery(
@@ -116,7 +118,7 @@ export function SetPromptVersionLabels({
<PopoverTrigger asChild data-version-trigger="true">
<div
className={cn(
"flex min-w-0 max-w-full cursor-pointer flex-wrap gap-1",
"flex w-fit min-w-0 max-w-full cursor-pointer flex-wrap gap-1",
!hasAccess && "cursor-not-allowed",
)}
>
@@ -143,7 +145,7 @@ export function SetPromptVersionLabels({
</div>
</PopoverTrigger>
<PopoverContent
className="max-h-[50vh] overflow-y-auto"
className="max-w-[90vw] sm:max-w-md"
align="start"
side="bottom"
sideOffset={5}
@@ -225,6 +227,7 @@ export function SetPromptVersionLabels({
: "default"
}
loading={mutatePromptVersionLabels.isLoading}
disabled={!labelsChanged}
className="w-full"
onClick={handleSubmitLabels}
>
@@ -18,10 +18,13 @@ import { Tabs, TabsList, TabsTrigger } from "@/src/components/ui/tabs";
import { Badge } from "@/src/components/ui/badge";
import { CodeView, JSONView } from "@/src/components/ui/CodeJsonViewer";
import { DetailPageNav } from "@/src/features/navigate-detail-pages/DetailPageNav";
import { PromptType } from "@/src/features/prompts/server/utils/validation";
import useProjectIdFromURL from "@/src/hooks/useProjectIdFromURL";
import { api } from "@/src/utils/api";
import { extractVariables } from "@langfuse/shared";
import {
extractVariables,
PRODUCTION_LABEL,
PromptType,
} from "@langfuse/shared";
import { PromptHistoryNode } from "./prompt-history";
import { JumpToPlaygroundButton } from "@/src/features/playground/page/components/JumpToPlaygroundButton";
import { ChatMlArraySchema } from "@/src/components/schemas/ChatMlSchema";
@@ -51,7 +54,6 @@ import { TagPromptDetailsPopover } from "@/src/features/tag/components/TagPrompt
import { SetPromptVersionLabels } from "@/src/features/prompts/components/SetPromptVersionLabels";
import { CommentDrawerButton } from "@/src/features/comments/CommentDrawerButton";
import { Command, CommandInput } from "@/src/components/ui/command";
import { PRODUCTION_LABEL } from "@/src/features/prompts/constants";
import { renderContentWithPromptButtons } from "@/src/features/prompts/components/renderContentWithPromptButtons";
import { PromptVariableListPreview } from "@/src/features/prompts/components/PromptVariableListPreview";
@@ -95,16 +97,18 @@ ${labels.length > 0 ? labels.map((label) => `const prompt = await langfuse.getPr
langfuse.getPrompt("${name}", ${version})
`;
export const PromptDetail = ({ promptName: promptNameProp }: { promptName?: string } = {}) => {
export const PromptDetail = ({
promptName: promptNameProp,
}: { promptName?: string } = {}) => {
const projectId = useProjectIdFromURL();
const capture = usePostHogClientCapture();
const router = useRouter();
const promptName = promptNameProp || (
router.query.promptName
const promptName =
promptNameProp ||
(router.query.promptName
? decodeURIComponent(router.query.promptName as string)
: ''
);
: "");
const [currentPromptVersion, setCurrentPromptVersion] = useQueryParam(
"version",
NumberParam,
@@ -12,7 +12,13 @@ import { DataTableToolbar } from "@/src/components/table/data-table-toolbar";
import { useQueryFilterState } from "@/src/features/filters/hooks/useFilterState";
import { useOrderByState } from "@/src/features/orderBy/hooks/useOrderByState";
import { promptsTableColsWithOptions } from "@/src/server/api/definitions/promptsTable";
import { NumberParam, StringParam, useQueryParams, withDefault } from "use-query-params";
import {
NumberParam,
StringParam,
useQueryParams,
withDefault,
useQueryParam,
} from "use-query-params";
import { createColumnHelper } from "@tanstack/react-table";
import { joinTableCoreAndMetrics } from "@/src/components/table/utils/joinTableCoreAndMetrics";
import { Skeleton } from "@/src/components/ui/skeleton";
@@ -40,7 +46,11 @@ type PromptTableRow = {
};
function createRow(
data: Partial<PromptTableRow> & { id: string; name: string; type: "folder" | "text" | "chat" }
data: Partial<PromptTableRow> & {
id: string;
name: string;
type: "folder" | "text" | "chat";
},
): PromptTableRow {
return {
version: undefined,
@@ -52,12 +62,14 @@ function createRow(
};
}
function isFolder(row: PromptTableRow): row is PromptTableRow & { type: "folder" } {
function isFolder(
row: PromptTableRow,
): row is PromptTableRow & { type: "folder" } {
return row.type === "folder";
}
function getDisplayName(fullPath: string, currentFolderPath: string): string {
return currentFolderPath === ''
return currentFolderPath === ""
? fullPath
: fullPath.substring(currentFolderPath.length + 1);
}
@@ -65,9 +77,9 @@ function getDisplayName(fullPath: string, currentFolderPath: string): string {
function createBreadcrumbItems(currentFolderPath: string) {
if (!currentFolderPath) return [];
const segments = currentFolderPath.split('/');
const segments = currentFolderPath.split("/");
return segments.map((name, i) => {
const folderPath = segments.slice(0, i + 1).join('/');
const folderPath = segments.slice(0, i + 1).join("/");
return {
name,
folderPath,
@@ -95,12 +107,27 @@ export function PromptTable() {
folder: StringParam,
});
const [searchQuery, setSearchQuery] = useQueryParam(
"search",
withDefault(StringParam, null),
);
// Reset pagination when search query changes
useEffect(() => {
setQueryParams({
pageIndex: 0,
pageSize: queryParams.pageSize,
folder: queryParams.folder,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery]);
const paginationState = {
pageIndex: queryParams.pageIndex,
pageSize: queryParams.pageSize,
};
const currentFolderPath = queryParams.folder || '';
const currentFolderPath = queryParams.folder || "";
const prompts = api.prompts.all.useQuery(
{
@@ -110,6 +137,7 @@ export function PromptTable() {
filter: filterState,
orderBy: orderByState,
pathPrefix: currentFolderPath,
searchQuery: searchQuery || undefined,
},
{
enabled: Boolean(projectId),
@@ -167,7 +195,7 @@ export function PromptTable() {
const prefix = `${currentFolderPath}/`;
if (promptName.startsWith(prefix)) {
const remainingPath = promptName.substring(prefix.length);
const slashIndex = remainingPath.indexOf('/');
const slashIndex = remainingPath.indexOf("/");
if (slashIndex > 0) {
// Subfolder
@@ -181,7 +209,7 @@ export function PromptTable() {
}
} else {
// Root level
const slashIndex = promptName.indexOf('/');
const slashIndex = promptName.indexOf("/");
if (slashIndex > 0) {
const folderName = promptName.substring(0, slashIndex);
uniqueFolders.add(folderName);
@@ -197,11 +225,13 @@ export function PromptTable() {
// Add folder rows
for (const folderPath of uniqueFolders) {
const folderName = getDisplayName(folderPath, currentFolderPath);
combinedRows.push(createRow({
id: folderPath,
name: folderName,
type: "folder",
}));
combinedRows.push(
createRow({
id: folderPath,
name: folderName,
type: "folder",
}),
);
}
// Add matching prompts
@@ -216,7 +246,7 @@ export function PromptTable() {
labels: prompt.labels,
tags: prompt.tags,
numberOfObservations: Number(prompt.observationCount ?? 0),
})
}),
);
}
@@ -284,7 +314,7 @@ export function PromptTable() {
setQueryParams({
folder: rowData.id,
pageIndex: 0,
pageSize: queryParams.pageSize
pageSize: queryParams.pageSize,
});
}}
title={displayName || ""}
@@ -397,7 +427,7 @@ export function PromptTable() {
return (
<>
{currentFolderPath && (
<div className="pt-2 ml-2">
<div className="ml-2 pt-2">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
@@ -407,38 +437,40 @@ export function PromptTable() {
setQueryParams({
folder: undefined,
pageIndex: 0,
pageSize: queryParams.pageSize
pageSize: queryParams.pageSize,
});
}}
>
<Home className="h-4 w-4" />
</BreadcrumbLink>
</BreadcrumbItem>
{createBreadcrumbItems(currentFolderPath).flatMap((item, index, array) => [
index > 0 && (
<BreadcrumbSeparator key={`sep-${item.folderPath}`}>
<Slash />
</BreadcrumbSeparator>
),
<BreadcrumbItem key={item.folderPath}>
{index === array.length - 1 ? (
<BreadcrumbPage>{item.name}</BreadcrumbPage>
) : (
<BreadcrumbLink
className="cursor-pointer hover:underline"
onClick={() => {
setQueryParams({
folder: item.folderPath,
pageIndex: 0,
pageSize: queryParams.pageSize
});
}}
>
{item.name}
</BreadcrumbLink>
)}
</BreadcrumbItem>
])}
{createBreadcrumbItems(currentFolderPath).flatMap(
(item, index, array) => [
index > 0 && (
<BreadcrumbSeparator key={`sep-${item.folderPath}`}>
<Slash />
</BreadcrumbSeparator>
),
<BreadcrumbItem key={item.folderPath}>
{index === array.length - 1 ? (
<BreadcrumbPage>{item.name}</BreadcrumbPage>
) : (
<BreadcrumbLink
className="cursor-pointer hover:underline"
onClick={() => {
setQueryParams({
folder: item.folderPath,
pageIndex: 0,
pageSize: queryParams.pageSize,
});
}}
>
{item.name}
</BreadcrumbLink>
)}
</BreadcrumbItem>,
],
)}
</BreadcrumbList>
</Breadcrumb>
</div>
@@ -451,6 +483,14 @@ export function PromptTable() {
filterState={filterState}
setFilterState={useDebounce(setFilterState)}
columnsWithCustomSelect={["labels", "tags"]}
searchConfig={{
metadataSearchFields: ["Name", "Tags"],
updateQuery: useDebounce(setSearchQuery, 300),
currentQuery: searchQuery ?? undefined,
tableAllowsFullTextSearch: false,
setSearchType: undefined,
searchType: undefined,
}}
/>
<DataTable
columns={promptColumns}
@@ -468,11 +508,12 @@ export function PromptTable() {
isError: false,
data: processedRowData.rows?.map((item) => ({
id: item.id,
name: item.type === 'folder'
? item.name
: currentFolderPath
? item.name.substring(currentFolderPath.length + 1)
: item.name,
name:
item.type === "folder"
? item.name
: currentFolderPath
? item.name.substring(currentFolderPath.length + 1)
: item.name,
version: item.version,
createdAt: item.createdAt,
type: item.type,
@@ -1,23 +1,23 @@
import { v4 as uuidv4 } from "uuid";
import {
type CreatePromptTRPCType,
PromptType,
} from "@/src/features/prompts/server/utils/validation";
import {
InvalidRequestError,
parsePromptDependencyTags,
jsonSchema,
type PromptDependency,
type Prompt,
type CreatePromptTRPCType,
LATEST_PROMPT_LABEL,
PromptType,
extractVariables,
} from "@langfuse/shared";
import { type PrismaClient } from "@langfuse/shared/src/db";
import { LATEST_PROMPT_LABEL } from "@/src/features/prompts/constants";
import { removeLabelsFromPreviousPromptVersions } from "@/src/features/prompts/server/utils/updatePromptLabels";
import { updatePromptTagsOnAllVersions } from "@/src/features/prompts/server/utils/updatePromptTags";
import {
PromptContentSchema,
PromptService,
redis,
extractPlaceholderNames,
} from "@langfuse/shared/src/server";
export type CreatePromptParams = CreatePromptTRPCType & {
@@ -34,6 +34,22 @@ type DuplicatePromptParams = {
prisma: PrismaClient;
};
const extractChatVariableAndPlaceholderNames = (
chatPrompt: Array<any>,
): { variables: string[], placeholders: string[] } => {
const placeholders = extractPlaceholderNames(chatPrompt);
const variables: string[] = [];
for (const message of chatPrompt) {
if (message && 'content' in message && typeof message.content === 'string') {
variables.push(...extractVariables(message.content));
}
}
return { variables: [...new Set(variables)], placeholders: [...new Set(placeholders)] };
};
export const createPrompt = async ({
projectId,
name,
@@ -57,6 +73,17 @@ export const createPrompt = async ({
);
}
// Prevent naming collisions between variables and placeholders
if (type === PromptType.Chat && Array.isArray(prompt)) {
const { variables, placeholders } = extractChatVariableAndPlaceholderNames(prompt);
const conflictingNames = variables.filter(v => placeholders.includes(v));
if (conflictingNames.length > 0) {
throw new InvalidRequestError(
`Cannot create prompt, variables and placeholders must be unique, the following are not: ${conflictingNames.join(", ")}`
);
}
}
const finalLabels = [...labels, LATEST_PROMPT_LABEL]; // Newly created prompts are always labeled as 'latest'
// If tags are undefined, use the tags from the latest prompt version
@@ -187,7 +214,7 @@ export const duplicatePrompt = async ({
});
if (!existingPrompt) {
throw new Error(`Existing prompt not found: ${promptId}`);
throw new InvalidRequestError(`Existing prompt not found: ${promptId}`);
}
// if defined as single version, duplicate current prompt as new prompt v1
@@ -255,7 +282,7 @@ export const duplicatePrompt = async ({
return promptResult;
});
// If you need the created prompt, fetch it separately since createMany doesn't return created records
// Fetch the created prompt to return
const createdPrompt = await prisma.prompt.findFirst({
where: {
name,
@@ -1,5 +1,8 @@
import { PRODUCTION_LABEL } from "@/src/features/prompts/constants";
import { InvalidRequestError, type Prompt } from "@langfuse/shared";
import {
InvalidRequestError,
PRODUCTION_LABEL,
type Prompt,
} from "@langfuse/shared";
import {
PromptService,
redis,
@@ -1,6 +1,5 @@
import { type GetPromptsMetaType } from "@/src/features/prompts/server/utils/validation";
import { promptsTableCols } from "@/src/server/api/definitions/promptsTable";
import { type FilterState } from "@langfuse/shared";
import { type GetPromptsMetaType, type FilterState } from "@langfuse/shared";
import { prisma } from "@langfuse/shared/src/db";
import { tableColumnsToSqlFilterAndPrefix } from "@langfuse/shared/src/server";
@@ -1,11 +1,13 @@
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/features/public-api/server/withMiddlewares";
import { authorizePromptRequestOrThrow } from "../utils/authorizePromptRequest";
import { LangfuseNotFoundError } from "@langfuse/shared";
import { PRODUCTION_LABEL } from "@/src/features/prompts/constants";
import {
GetPromptByNameSchema,
LangfuseNotFoundError,
PRODUCTION_LABEL,
} from "@langfuse/shared";
import { RateLimitService } from "@/src/features/public-api/server/RateLimitService";
const getPromptNameHandler = async (
@@ -2,15 +2,15 @@ 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/features/public-api/server/withMiddlewares";
import { prisma } from "@langfuse/shared/src/db";
import { authorizePromptRequestOrThrow } from "../utils/authorizePromptRequest";
import { RateLimitService } from "@/src/features/public-api/server/RateLimitService";
import { InvalidRequestError } from "@langfuse/shared";
import {
CreatePromptSchema,
GetPromptsMetaSchema,
InvalidRequestError,
} from "@langfuse/shared";
import { auditLog } from "@/src/features/audit-logs/auditLog";
const getPromptsHandler = async (req: NextApiRequest, res: NextApiResponse) => {
@@ -1,11 +1,6 @@
import { z } from "zod/v4";
import { auditLog } from "@/src/features/audit-logs/auditLog";
import {
CreatePromptTRPCSchema,
PromptLabelSchema,
PromptType,
} from "@/src/features/prompts/server/utils/validation";
import { throwIfNoProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
import { throwIfNoEntitlement } from "@/src/features/entitlements/server/hasEntitlement";
import {
@@ -17,12 +12,15 @@ import { createPrompt, duplicatePrompt } from "../actions/createPrompt";
import { checkHasProtectedLabels } from "../utils/checkHasProtectedLabels";
import { promptsTableCols } from "@/src/server/api/definitions/promptsTable";
import {
CreatePromptTRPCSchema,
InvalidRequestError,
LATEST_PROMPT_LABEL,
optionalPaginationZod,
paginationZod,
PromptLabelSchema,
PromptType,
} from "@langfuse/shared";
import { orderBy, singleFilter } from "@langfuse/shared";
import { LATEST_PROMPT_LABEL } from "@/src/features/prompts/constants";
import {
orderByToPrismaSql,
PromptService,
@@ -42,6 +40,7 @@ const PromptFilterOptions = z.object({
orderBy: orderBy,
...paginationZod,
pathPrefix: z.string().optional(),
searchQuery: z.string().optional(),
});
export const promptRouter = createTRPCRouter({
@@ -89,7 +88,11 @@ export const promptRouter = createTRPCRouter({
);
const pathFilter = input.pathPrefix
? Prisma.sql` AND (p.name LIKE ${input.pathPrefix + '/%'} OR p.name = ${input.pathPrefix})`
? Prisma.sql` AND (p.name LIKE ${input.pathPrefix + "/%"} OR p.name = ${input.pathPrefix})`
: Prisma.empty;
const searchFilter = input.searchQuery
? Prisma.sql` AND (p.name ILIKE ${`%${input.searchQuery}%`} OR EXISTS (SELECT 1 FROM UNNEST(p.tags) AS tag WHERE tag ILIKE ${`%${input.searchQuery}%`}))`
: Prisma.empty;
const [prompts, promptCount] = await Promise.all([
@@ -113,6 +116,7 @@ export const promptRouter = createTRPCRouter({
input.limit,
input.page,
pathFilter,
searchFilter,
),
),
// promptCount
@@ -125,6 +129,7 @@ export const promptRouter = createTRPCRouter({
1, // limit
0, // page,
pathFilter,
searchFilter,
),
),
]);
@@ -1235,6 +1240,7 @@ const generatePromptQuery = (
limit: number,
page: number,
pathFilter: Prisma.Sql = Prisma.empty,
searchFilter: Prisma.Sql = Prisma.empty,
) => {
return Prisma.sql`
SELECT
@@ -1246,11 +1252,13 @@ const generatePromptQuery = (
WHERE "project_id" = ${projectId}
${filterCondition}
${pathFilter}
${searchFilter}
GROUP BY name
)
AND "project_id" = ${projectId}
${filterCondition}
${pathFilter}
${searchFilter}
${orderCondition}
LIMIT ${limit} OFFSET ${page * limit};
`;
+1 -4
View File
@@ -1,7 +1,4 @@
import {
LATEST_PROMPT_LABEL,
PRODUCTION_LABEL,
} from "@/src/features/prompts/constants";
import { LATEST_PROMPT_LABEL, PRODUCTION_LABEL } from "@langfuse/shared";
export const isReservedPromptLabel = (label: string) => {
return [PRODUCTION_LABEL, LATEST_PROMPT_LABEL].includes(label);
@@ -106,6 +106,10 @@ const viewMappings: Record<z.infer<typeof views>, Record<string, string>[]> = {
uiTableName: "Score Source",
viewName: "source",
},
{
uiTableName: "Score Value",
viewName: "value",
},
{
uiTableName: "Scores Data Type",
viewName: "dataType",
@@ -156,6 +160,10 @@ const viewMappings: Record<z.infer<typeof views>, Record<string, string>[]> = {
uiTableName: "Score Source",
viewName: "source",
},
{
uiTableName: "Score String Value",
viewName: "stringValue",
},
{
uiTableName: "Scores Data Type",
viewName: "dataType",
@@ -222,6 +230,18 @@ const isLegacyUiTableFilter = (
clickhouseTableName: "traces",
clickhouseSelect: 't."metadata"',
},
{
uiTableName: "Score Value",
uiTableId: "value",
clickhouseTableName: "scores",
clickhouseSelect: 's."value"',
},
{
uiTableName: "Score String Value",
uiTableId: "stringValue",
clickhouseTableName: "scores",
clickhouseSelect: 's."string_value"',
},
])
.some((columnDef) => columnDef.uiTableName === filter.column);
};
+5
View File
@@ -501,6 +501,11 @@ export const scoresNumericView: ViewDeclarationType = {
"Scores are flexible objects that are used for evaluations. This view contains numeric scores.",
dimensions: {
...scoreBaseDimensions,
value: {
sql: "value",
type: "number",
description: "Value of the score.",
},
},
measures: {
count: {
+1 -1
View File
@@ -114,7 +114,7 @@ export const query = z
}),
)
.nullable(),
// Chart configuration for chart-specific settings like histogram bins
// Chart configuration for chart-specific settings like histogram bins and pivot table dimensions
chartConfig: z
.object({
type: z.string(),

Some files were not shown because too many files have changed in this diff Show More