Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d54cbc6c9b | ||
|
|
332f6658d9 | ||
|
|
25a559293b | ||
|
|
9ff8643489 | ||
|
|
dd7e4f344e | ||
|
|
fe089e15f2 | ||
|
|
9f8793e26a | ||
|
|
a1cb4c727b | ||
|
|
b39eb5445a | ||
|
|
b611301658 | ||
|
|
c3fd8a74dc | ||
|
|
aff8421dde | ||
|
|
c442c4290e | ||
|
|
01bc983b97 | ||
|
|
e2015bc52b | ||
|
|
91589d20c6 | ||
|
|
50f9444c36 | ||
|
|
4432223fd2 | ||
|
|
e3d58ff920 | ||
|
|
ded00e6fdf | ||
|
|
3fd1cc021c | ||
|
|
4b2e68017b | ||
|
|
a52f87c64a | ||
|
|
7500575a96 | ||
|
|
105284d756 | ||
|
|
29f7ace2ce | ||
|
|
8fd17b52d1 | ||
|
|
484af10463 | ||
|
|
33d43ca9bd | ||
|
|
3658620540 | ||
|
|
83e1c5a3df | ||
|
|
5efcef593f | ||
|
|
6c84c54a4a | ||
|
|
9b3d77e1de |
+2
-1
@@ -103,6 +103,7 @@ OTEL_SERVICE_NAME="langfuse"
|
||||
# AUTH_CUSTOM_SCOPE="openid email profile" # optional
|
||||
# AUTH_CUSTOM_CLIENT_AUTH_METHOD="client_secret_basic" # optional
|
||||
# AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_CUSTOM_ID_TOKEN=false # optional, default is true
|
||||
|
||||
# Transactional email, optional
|
||||
# Defines the email address to use as the from address.
|
||||
@@ -253,4 +254,4 @@ OTEL_SERVICE_NAME="langfuse"
|
||||
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS=
|
||||
# LANGFUSE_INGESTION_CLICKHOUSE_MAX_ATTEMPTS=
|
||||
|
||||
## END Langfuse V3 Ingestion
|
||||
## END Langfuse V3 Ingestion
|
||||
|
||||
@@ -3,9 +3,13 @@ name: Codespell
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches:
|
||||
- "main"
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches:
|
||||
- "**"
|
||||
merge_group:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"@langfuse/shared": "workspace:*",
|
||||
"@opentelemetry/api": ">=1.0.0 <1.10.0",
|
||||
"axios": "^1.7.7",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"next": "^14.2.21",
|
||||
"next-auth": "^4.24.11",
|
||||
"zod": "^3.23.8"
|
||||
|
||||
@@ -12,7 +12,7 @@ groups:
|
||||
# namespaceExport: Langfuse
|
||||
# allowCustomFetcher: true
|
||||
- name: fernapi/fern-openapi
|
||||
version: 0.0.26
|
||||
version: 0.1.7
|
||||
output:
|
||||
location: local-file-system
|
||||
path: ../../../web/public/generated/api-client
|
||||
|
||||
@@ -128,7 +128,7 @@ types:
|
||||
modelParameters: optional<map<string, commons.MapValue>>
|
||||
usage: optional<IngestionUsage>
|
||||
usageDetails: optional<UsageDetails>
|
||||
costDetails: optional<map<string, float>>
|
||||
costDetails: optional<map<string, double>>
|
||||
promptName: optional<string>
|
||||
promptVersion: optional<integer>
|
||||
|
||||
@@ -141,7 +141,7 @@ types:
|
||||
usage: optional<IngestionUsage>
|
||||
promptName: optional<string>
|
||||
usageDetails: optional<UsageDetails>
|
||||
costDetails: optional<map<string, float>>
|
||||
costDetails: optional<map<string, double>>
|
||||
promptVersion: optional<integer>
|
||||
|
||||
ObservationBody:
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/fern-api/fern/main/fern.schema.json
|
||||
imports:
|
||||
prompts: ./prompts.yml
|
||||
pagination: ./utils/pagination.yml
|
||||
service:
|
||||
auth: true
|
||||
base-path: /api/public/v2
|
||||
endpoints:
|
||||
update:
|
||||
docs: Update labels for a specific prompt version
|
||||
method: PATCH
|
||||
path: /prompts/{promptName}/version/{version}
|
||||
path-parameters:
|
||||
promptName:
|
||||
type: string
|
||||
docs: The name of the prompt
|
||||
version:
|
||||
type: integer
|
||||
docs: Version of the prompt to update
|
||||
request:
|
||||
name: UpdatePromptRequest
|
||||
body:
|
||||
properties:
|
||||
newLabels:
|
||||
type: list<string>
|
||||
docs: New labels for the prompt version. Labels are unique across versions. The "latest" label is reserved and managed by Langfuse.
|
||||
response: prompts.Prompt
|
||||
@@ -102,7 +102,6 @@ types:
|
||||
tags:
|
||||
type: optional<list<string>>
|
||||
docs: List of tags to apply to all versions of this prompt.
|
||||
|
||||
Prompt:
|
||||
union:
|
||||
chat: ChatPrompt
|
||||
|
||||
@@ -59,7 +59,7 @@ service:
|
||||
type: optional<commons.ScoreDataType>
|
||||
docs: Retrieve only scores with a specific dataType.
|
||||
traceTags:
|
||||
type: optional<list<string>>
|
||||
type: optional<string>
|
||||
allow-multiple: true
|
||||
docs: Only scores linked to traces that include all of these tags will be returned.
|
||||
response: GetScoresResponse
|
||||
|
||||
@@ -3,7 +3,7 @@ groups:
|
||||
local:
|
||||
generators:
|
||||
- name: fernapi/fern-openapi
|
||||
version: 0.0.31
|
||||
version: 0.1.7
|
||||
output:
|
||||
location: local-file-system
|
||||
path: ../../../web/public/generated/api
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse",
|
||||
"version": "3.13.0",
|
||||
"version": "3.17.0",
|
||||
"author": "engineering@langfuse.com",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
@@ -86,6 +86,9 @@
|
||||
"jsonpath-plus": "10.0.7",
|
||||
"nanoid": "^3.3.8",
|
||||
"katex": "^0.16.21"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"next-auth@4.24.11": "patches/next-auth@4.24.11.patch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
"dd-trace": "^5.23.1",
|
||||
"decimal.js": "^10.4.3",
|
||||
"exponential-backoff": "^3.1.1",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"ioredis": "^5.4.1",
|
||||
"kysely": "^0.27.4",
|
||||
"langchain": "^0.3.6",
|
||||
|
||||
@@ -304,6 +304,8 @@ export type LlmApiKeys = {
|
||||
base_url: string | null;
|
||||
custom_models: Generated<string[]>;
|
||||
with_default_models: Generated<boolean>;
|
||||
extra_headers: string | null;
|
||||
extra_header_keys: Generated<string[]>;
|
||||
config: unknown | null;
|
||||
project_id: string;
|
||||
};
|
||||
@@ -466,6 +468,7 @@ export type Project = {
|
||||
updated_at: Generated<Timestamp>;
|
||||
deleted_at: Timestamp | null;
|
||||
name: string;
|
||||
retention_days: number | null;
|
||||
};
|
||||
export type ProjectMembership = {
|
||||
org_membership_id: string;
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "llm_api_keys"
|
||||
ADD COLUMN "extra_headers" TEXT,
|
||||
ADD COLUMN "extra_header_keys" TEXT[] NOT NULL DEFAULT '{}'::TEXT[];
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "projects"
|
||||
ADD COLUMN "retention_days" INTEGER;
|
||||
@@ -115,6 +115,7 @@ model Project {
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
name String
|
||||
retentionDays Int? @map("retention_days")
|
||||
projectMembers ProjectMembership[]
|
||||
organization Organization @relation(fields: [orgId], references: [id], onUpdate: Cascade, onDelete: Cascade)
|
||||
traces Trace[]
|
||||
@@ -192,6 +193,8 @@ model LlmApiKeys {
|
||||
baseURL String? @map("base_url")
|
||||
customModels String[] @default([]) @map("custom_models")
|
||||
withDefaultModels Boolean @default(true) @map("with_default_models")
|
||||
extraHeaders String? @map("extra_headers")
|
||||
extraHeaderKeys String[] @default([]) @map("extra_header_keys")
|
||||
config Json?
|
||||
|
||||
projectId String @map("project_id")
|
||||
|
||||
@@ -17,7 +17,6 @@ export function CustomSSOProvider<P extends CustomSSOUser>(
|
||||
wellKnown: `${options.issuer}/.well-known/openid-configuration`,
|
||||
authorization: { params: { scope: "openid email profile" } }, // overridden by options.authorization to be able to set custom scopes, deep merged with this default
|
||||
checks: ["pkce", "state"],
|
||||
idToken: true,
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.sub,
|
||||
|
||||
@@ -8,6 +8,7 @@ export * from "./auth/apiKeys";
|
||||
export * from "./auth/customSsoProvider";
|
||||
export * from "./auth/gitHubEnterpriseProvider";
|
||||
export * from "./llm/fetchLLMCompletion";
|
||||
export * from "./llm/utils";
|
||||
export * from "./llm/types";
|
||||
export * from "./utils/DatabaseReadStream";
|
||||
export * from "./utils/transforms";
|
||||
@@ -31,6 +32,8 @@ export * from "./redis/batchExport";
|
||||
export * from "./redis/ingestionQueue";
|
||||
export * from "./redis/postHogIntegrationQueue";
|
||||
export * from "./redis/postHogIntegrationProcessingQueue";
|
||||
export * from "./redis/dataRetentionQueue";
|
||||
export * from "./redis/dataRetentionProcessingQueue";
|
||||
export * from "./redis/coreDataS3ExportQueue";
|
||||
export * from "./redis/meteringDataPostgresExportQueue";
|
||||
export * from "./redis/experimentCreateQueue";
|
||||
|
||||
@@ -226,6 +226,14 @@ export const recordHistogram = (
|
||||
dd.dogstatsd.histogram(stat, value, tags);
|
||||
};
|
||||
|
||||
export const recordDistribution = (
|
||||
stat: string,
|
||||
value?: number | undefined,
|
||||
tags?: { [tag: string]: string | number } | undefined,
|
||||
) => {
|
||||
dd.dogstatsd.distribution(stat, value, tags);
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a queue name to the matching datadog metric name.
|
||||
* Consumer only needs to append the relevant suffix.
|
||||
|
||||
@@ -49,6 +49,7 @@ type LLMCompletionParams = {
|
||||
callbacks?: BaseCallbackHandler[];
|
||||
baseURL?: string;
|
||||
apiKey: string;
|
||||
extraHeaders?: Record<string, string>;
|
||||
maxRetries?: number;
|
||||
config?: Record<string, string> | null;
|
||||
traceParams?: TraceParams;
|
||||
@@ -100,6 +101,7 @@ export async function fetchLLMCompletion(
|
||||
maxRetries,
|
||||
config,
|
||||
traceParams,
|
||||
extraHeaders,
|
||||
} = params;
|
||||
|
||||
let finalCallbacks: BaseCallbackHandler[] | undefined = callbacks ?? [];
|
||||
@@ -176,6 +178,7 @@ export async function fetchLLMCompletion(
|
||||
maxRetries,
|
||||
configuration: {
|
||||
baseURL,
|
||||
defaultHeaders: extraHeaders,
|
||||
},
|
||||
timeout: 1000 * 60 * 2, // 2 minutes timeout
|
||||
});
|
||||
|
||||
@@ -148,6 +148,8 @@ export const LLMApiKeySchema = z
|
||||
provider: z.string(),
|
||||
displaySecretKey: z.string(),
|
||||
secretKey: z.string(),
|
||||
extraHeaders: z.string().nullish(),
|
||||
extraHeaderKeys: z.array(z.string()),
|
||||
baseURL: z.string().nullable(),
|
||||
customModels: z.array(z.string()),
|
||||
withDefaultModels: z.boolean(),
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { decrypt } from "../../encryption";
|
||||
|
||||
const ExtraHeaderSchema = z.record(z.string(), z.string());
|
||||
|
||||
export function decryptAndParseExtraHeaders(
|
||||
extraHeaders: string | null | undefined,
|
||||
) {
|
||||
if (!extraHeaders) return;
|
||||
|
||||
return ExtraHeaderSchema.parse(decrypt(extraHeaders));
|
||||
}
|
||||
@@ -51,6 +51,10 @@ export const ExperimentCreateEventSchema = z.object({
|
||||
runId: z.string(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
export const DataRetentionProcessingEventSchema = z.object({
|
||||
projectId: z.string(),
|
||||
retention: z.number(),
|
||||
});
|
||||
|
||||
export type BatchExportJobType = z.infer<typeof BatchExportJobSchema>;
|
||||
export type TraceQueueEventType = z.infer<typeof TraceQueueEventSchema>;
|
||||
@@ -67,6 +71,9 @@ export type ExperimentCreateEventType = z.infer<
|
||||
export type PostHogIntegrationProcessingEventType = z.infer<
|
||||
typeof PostHogIntegrationProcessingEventSchema
|
||||
>;
|
||||
export type DataRetentionProcessingEventType = z.infer<
|
||||
typeof DataRetentionProcessingEventSchema
|
||||
>;
|
||||
|
||||
export enum QueueName {
|
||||
TraceUpsert = "trace-upsert", // Ingestion pipeline adds events on each Trace upsert
|
||||
@@ -83,6 +90,8 @@ export enum QueueName {
|
||||
PostHogIntegrationProcessingQueue = "posthog-integration-processing-queue",
|
||||
CoreDataS3ExportQueue = "core-data-s3-export-queue",
|
||||
MeteringDataPostgresExportQueue = "metering-data-postgres-export-queue",
|
||||
DataRetentionQueue = "data-retention-queue",
|
||||
DataRetentionProcessingQueue = "data-retention-processing-queue",
|
||||
}
|
||||
|
||||
export enum QueueJobs {
|
||||
@@ -100,6 +109,8 @@ export enum QueueJobs {
|
||||
PostHogIntegrationProcessingJob = "posthog-integration-processing-job",
|
||||
CoreDataS3ExportJob = "core-data-s3-export-job",
|
||||
MeteringDataPostgresExportJob = "metering-data-postgres-export-job",
|
||||
DataRetentionJob = "data-retention-job",
|
||||
DataRetentionProcessingJob = "data-retention-processing-job",
|
||||
}
|
||||
|
||||
export type TQueueJobTypes = {
|
||||
@@ -163,4 +174,10 @@ export type TQueueJobTypes = {
|
||||
payload: PostHogIntegrationProcessingEventType;
|
||||
name: QueueJobs.PostHogIntegrationProcessingJob;
|
||||
};
|
||||
[QueueName.DataRetentionProcessingQueue]: {
|
||||
timestamp: Date;
|
||||
id: string;
|
||||
payload: DataRetentionProcessingEventType;
|
||||
name: QueueJobs.DataRetentionProcessingJob;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Queue } from "bullmq";
|
||||
import { QueueName } from "../queues";
|
||||
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
|
||||
import { logger } from "../logger";
|
||||
|
||||
export class DataRetentionProcessingQueue {
|
||||
private static instance: Queue | null = null;
|
||||
|
||||
public static getInstance(): Queue | null {
|
||||
if (DataRetentionProcessingQueue.instance) {
|
||||
return DataRetentionProcessingQueue.instance;
|
||||
}
|
||||
|
||||
const newRedis = createNewRedisInstance({
|
||||
enableOfflineQueue: false,
|
||||
...redisQueueRetryOptions,
|
||||
});
|
||||
|
||||
DataRetentionProcessingQueue.instance = newRedis
|
||||
? new Queue(QueueName.DataRetentionProcessingQueue, {
|
||||
connection: newRedis,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 10000,
|
||||
attempts: 5,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 5000,
|
||||
},
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
DataRetentionProcessingQueue.instance?.on("error", (err) => {
|
||||
logger.error("DataRetentionProcessingQueue error", err);
|
||||
});
|
||||
|
||||
return DataRetentionProcessingQueue.instance;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Queue } from "bullmq";
|
||||
import { QueueName, QueueJobs } from "../queues";
|
||||
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
|
||||
import { logger } from "../logger";
|
||||
|
||||
export class DataRetentionQueue {
|
||||
private static instance: Queue | null = null;
|
||||
|
||||
public static getInstance(): Queue | null {
|
||||
if (DataRetentionQueue.instance) {
|
||||
return DataRetentionQueue.instance;
|
||||
}
|
||||
|
||||
const newRedis = createNewRedisInstance({
|
||||
enableOfflineQueue: false,
|
||||
...redisQueueRetryOptions,
|
||||
});
|
||||
|
||||
DataRetentionQueue.instance = newRedis
|
||||
? new Queue(QueueName.DataRetentionQueue, {
|
||||
connection: newRedis,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 100,
|
||||
attempts: 5,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 5000,
|
||||
},
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
DataRetentionQueue.instance?.on("error", (err) => {
|
||||
logger.error("DataRetentionQueue error", err);
|
||||
});
|
||||
|
||||
if (DataRetentionQueue.instance) {
|
||||
logger.debug("Scheduling jobs for DataRetentionQueue");
|
||||
DataRetentionQueue.instance
|
||||
.add(
|
||||
QueueJobs.DataRetentionJob,
|
||||
{},
|
||||
{
|
||||
repeat: { pattern: "15 3 * * *" }, // every day at 3:15am
|
||||
},
|
||||
)
|
||||
.catch((err) => {
|
||||
logger.error("Error adding DataRetentionQueue schedule", err);
|
||||
});
|
||||
}
|
||||
|
||||
return DataRetentionQueue.instance;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import { PostHogIntegrationQueue } from "./postHogIntegrationQueue";
|
||||
import { PostHogIntegrationProcessingQueue } from "./postHogIntegrationProcessingQueue";
|
||||
import { CoreDataS3ExportQueue } from "./coreDataS3ExportQueue";
|
||||
import { MeteringDataPostgresExportQueue } from "./meteringDataPostgresExportQueue";
|
||||
import { DataRetentionQueue } from "./dataRetentionQueue";
|
||||
import { DataRetentionProcessingQueue } from "./dataRetentionProcessingQueue";
|
||||
|
||||
export function getQueue(queueName: QueueName): Queue | null {
|
||||
switch (queueName) {
|
||||
@@ -44,6 +46,10 @@ export function getQueue(queueName: QueueName): Queue | null {
|
||||
return CoreDataS3ExportQueue.getInstance();
|
||||
case QueueName.MeteringDataPostgresExportQueue:
|
||||
return MeteringDataPostgresExportQueue.getInstance();
|
||||
case QueueName.DataRetentionQueue:
|
||||
return DataRetentionQueue.getInstance();
|
||||
case QueueName.DataRetentionProcessingQueue:
|
||||
return DataRetentionProcessingQueue.getInstance();
|
||||
default:
|
||||
const exhaustiveCheckDefault: never = queueName;
|
||||
throw new Error(`Queue ${queueName} not found`);
|
||||
|
||||
@@ -30,7 +30,7 @@ import { env } from "../../env";
|
||||
export const checkTraceExists = async (
|
||||
projectId: string,
|
||||
traceId: string,
|
||||
timestamp: Date | undefined,
|
||||
timestamp: Date,
|
||||
filter: FilterState,
|
||||
): Promise<boolean> => {
|
||||
const { tracesFilter } = getProjectIdDefaultFilter(projectId, {
|
||||
@@ -61,18 +61,22 @@ export const checkTraceExists = async (
|
||||
const query = `
|
||||
WITH observations_agg AS (
|
||||
SELECT
|
||||
|
||||
multiIf(
|
||||
arrayExists(x -> x = 'ERROR', groupArray(level)), 'ERROR',
|
||||
arrayExists(x -> x = 'WARNING', groupArray(level)), 'WARNING',
|
||||
arrayExists(x -> x = 'DEFAULT', groupArray(level)), 'DEFAULT',
|
||||
'DEBUG'
|
||||
) AS level,
|
||||
) AS aggregated_level,
|
||||
countIf(level = 'ERROR') as error_count,
|
||||
countIf(level = 'WARNING') as warning_count,
|
||||
countIf(level = 'DEFAULT') as default_count,
|
||||
countIf(level = 'DEBUG') as debug_count,
|
||||
trace_id,
|
||||
project_id
|
||||
FROM observations o FINAL
|
||||
WHERE o.project_id = {projectId: String}
|
||||
${timeStampFilter ? `AND o.start_time >= {traceTimestamp: DateTime64(3)} - ${OBSERVATIONS_TO_TRACE_INTERVAL}` : ""}
|
||||
AND o.start_time >= {timestamp: DateTime64(3)} - ${OBSERVATIONS_TO_TRACE_INTERVAL}
|
||||
GROUP BY trace_id, project_id
|
||||
)
|
||||
SELECT
|
||||
@@ -82,7 +86,7 @@ export const checkTraceExists = async (
|
||||
${observationFilterRes ? `INNER JOIN observations_agg o ON t.id = o.trace_id AND t.project_id = o.project_id` : ""}
|
||||
WHERE ${tracesFilterRes.query}
|
||||
AND t.project_id = {projectId: String}
|
||||
${timestamp ? `AND timestamp >= {timestamp: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}` : ""}
|
||||
AND timestamp >= {timestamp: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}
|
||||
GROUP BY t.id, t.project_id
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Readable } from "stream";
|
||||
import {
|
||||
DeleteObjectsCommand,
|
||||
GetObjectCommand,
|
||||
ListObjectsV2Command,
|
||||
PutObjectCommand,
|
||||
@@ -45,6 +46,8 @@ export interface StorageService {
|
||||
contentType: string;
|
||||
contentLength: number;
|
||||
}): Promise<string>;
|
||||
|
||||
deleteFiles(paths: string[]): Promise<void>;
|
||||
}
|
||||
|
||||
export class StorageServiceFactory {
|
||||
@@ -201,6 +204,25 @@ class AzureBlobStorageService implements StorageService {
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteFiles(paths: string[]): Promise<void> {
|
||||
try {
|
||||
await this.createContainerIfNotExists();
|
||||
|
||||
await Promise.all(
|
||||
paths.map(async (path) => {
|
||||
const blobClient = this.client.getBlobClient(path);
|
||||
await blobClient.deleteIfExists();
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`Failed to delete files from Azure Blob Storage ${paths}`,
|
||||
err,
|
||||
);
|
||||
throw Error("Failed to delete files from Azure Blob Storage");
|
||||
}
|
||||
}
|
||||
|
||||
public async listFiles(
|
||||
prefix: string,
|
||||
): Promise<{ file: string; createdAt: Date }[]> {
|
||||
@@ -415,6 +437,37 @@ class S3StorageService implements StorageService {
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteFiles(paths: string[]): Promise<void> {
|
||||
const chunkSize = 900;
|
||||
const chunks = [];
|
||||
|
||||
for (let i = 0; i < paths.length; i += chunkSize) {
|
||||
chunks.push(paths.slice(i, i + chunkSize));
|
||||
}
|
||||
|
||||
try {
|
||||
for (const chunk of chunks) {
|
||||
const command = new DeleteObjectsCommand({
|
||||
Bucket: this.bucketName,
|
||||
Delete: {
|
||||
Objects: chunk.map((path) => ({ Key: path })),
|
||||
Quiet: true,
|
||||
},
|
||||
});
|
||||
const result = await this.client.send(command);
|
||||
if (result?.Errors && result?.Errors?.length > 0) {
|
||||
logger.error("Failed to delete files from S3", {
|
||||
errors: result.Errors,
|
||||
});
|
||||
throw new Error("Failed to delete files from S3");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Failed to delete files from S3`, err);
|
||||
throw new Error("Failed to delete files from S3");
|
||||
}
|
||||
}
|
||||
|
||||
public async getSignedUploadUrl(params: {
|
||||
path: string;
|
||||
ttlSeconds: number;
|
||||
|
||||
@@ -70,6 +70,10 @@ export type TracesMetricsUiReturnType = {
|
||||
scores: ScoreAggregate;
|
||||
usageDetails: Record<string, number>;
|
||||
costDetails: Record<string, number>;
|
||||
errorCount: bigint;
|
||||
warningCount: bigint;
|
||||
defaultCount: bigint;
|
||||
debugCount: bigint;
|
||||
};
|
||||
|
||||
export const convertToUiTableRows = (
|
||||
@@ -125,6 +129,10 @@ export const convertToUITableMetrics = (
|
||||
? new Decimal(row.cost_details.output)
|
||||
: null,
|
||||
level: row.level,
|
||||
debugCount: BigInt(row.debug_count ?? 0),
|
||||
warningCount: BigInt(row.warning_count ?? 0),
|
||||
errorCount: BigInt(row.error_count ?? 0),
|
||||
defaultCount: BigInt(row.default_count ?? 0),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -138,6 +146,10 @@ export type TracesTableMetricsClickhouseReturnType = {
|
||||
usage_details: Record<string, number>;
|
||||
cost_details: Record<string, number>;
|
||||
scores_avg: Array<{ name: string; avg_value: number }>;
|
||||
error_count: number | null;
|
||||
warning_count: number | null;
|
||||
default_count: number | null;
|
||||
debug_count: number | null;
|
||||
};
|
||||
|
||||
export type FetchTracesTableProps = {
|
||||
@@ -225,7 +237,11 @@ const getTracesTableGeneric = async <T>(props: FetchTracesTableProps) => {
|
||||
os.latency_milliseconds / 1000 as latency,
|
||||
os.cost_details as cost_details,
|
||||
os.usage_details as usage_details,
|
||||
os.level as level,
|
||||
os.aggregated_level as level,
|
||||
os.error_count as error_count,
|
||||
os.warning_count as warning_count,
|
||||
os.default_count as default_count,
|
||||
os.debug_count as debug_count,
|
||||
os.observation_count as observation_count,
|
||||
s.scores_avg as scores_avg,
|
||||
t.public as public`;
|
||||
@@ -245,7 +261,6 @@ const getTracesTableGeneric = async <T>(props: FetchTracesTableProps) => {
|
||||
t.public as public`;
|
||||
break;
|
||||
default:
|
||||
const exhaustiveCheckDefault: never = select;
|
||||
throw new Error(`Unknown select type: ${select}`);
|
||||
}
|
||||
|
||||
@@ -361,12 +376,16 @@ const getTracesTableGeneric = async <T>(props: FetchTracesTableProps) => {
|
||||
sumMap(usage_details) as usage_details,
|
||||
SUM(total_cost) AS total_cost,
|
||||
date_diff('millisecond', least(min(start_time), min(end_time)), greatest(max(start_time), max(end_time))) as latency_milliseconds,
|
||||
countIf(level = 'ERROR') as error_count,
|
||||
countIf(level = 'WARNING') as warning_count,
|
||||
countIf(level = 'DEFAULT') as default_count,
|
||||
countIf(level = 'DEBUG') as debug_count,
|
||||
multiIf(
|
||||
arrayExists(x -> x = 'ERROR', groupArray(level)), 'ERROR',
|
||||
arrayExists(x -> x = 'WARNING', groupArray(level)), 'WARNING',
|
||||
arrayExists(x -> x = 'DEFAULT', groupArray(level)), 'DEFAULT',
|
||||
'DEBUG'
|
||||
) AS level,
|
||||
) AS aggregated_level,
|
||||
sumMap(cost_details) as cost_details,
|
||||
trace_id,
|
||||
project_id
|
||||
|
||||
@@ -12,7 +12,7 @@ export const tracesTableUiColumnDefinitions: UiColumnMapping[] = [
|
||||
uiTableName: "Level",
|
||||
uiTableId: "level",
|
||||
clickhouseTableName: "observations",
|
||||
clickhouseSelect: "level",
|
||||
clickhouseSelect: "aggregated_level",
|
||||
},
|
||||
{
|
||||
uiTableName: "ID",
|
||||
@@ -68,6 +68,30 @@ export const tracesTableUiColumnDefinitions: UiColumnMapping[] = [
|
||||
clickhouseTableName: "traces",
|
||||
clickhouseSelect: "tags",
|
||||
},
|
||||
{
|
||||
uiTableName: "Warning Level Count",
|
||||
uiTableId: "warningCount",
|
||||
clickhouseTableName: "observations",
|
||||
clickhouseSelect: "warning_count",
|
||||
},
|
||||
{
|
||||
uiTableName: "Error Level Count",
|
||||
uiTableId: "errorCount",
|
||||
clickhouseTableName: "observations",
|
||||
clickhouseSelect: "error_count",
|
||||
},
|
||||
{
|
||||
uiTableName: "Default Level Count",
|
||||
uiTableId: "defaultCount",
|
||||
clickhouseTableName: "observations",
|
||||
clickhouseSelect: "default_count",
|
||||
},
|
||||
{
|
||||
uiTableName: "Debug Level Count",
|
||||
uiTableId: "debugCount",
|
||||
clickhouseTableName: "observations",
|
||||
clickhouseSelect: "debug_count",
|
||||
},
|
||||
{
|
||||
uiTableName: "Input Tokens",
|
||||
uiTableId: "inputTokens",
|
||||
|
||||
@@ -88,6 +88,30 @@ export const tracesTableCols: ColumnDefinition[] = [
|
||||
internal: 'generation_metrics."completionTokens"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Error Level Count",
|
||||
id: "errorCount",
|
||||
type: "number",
|
||||
internal: 'generation_metrics."errorCount"',
|
||||
},
|
||||
{
|
||||
name: "Warning Level Count",
|
||||
id: "warningCount",
|
||||
type: "number",
|
||||
internal: 'generation_metrics."warningCount"',
|
||||
},
|
||||
{
|
||||
name: "Default Level Count",
|
||||
id: "defaultCount",
|
||||
type: "number",
|
||||
internal: 'generation_metrics."defaultCount"',
|
||||
},
|
||||
{
|
||||
name: "Debug Level Count",
|
||||
id: "debugCount",
|
||||
type: "number",
|
||||
internal: 'generation_metrics."debugCount"',
|
||||
},
|
||||
{
|
||||
name: "Total Tokens",
|
||||
id: "totalTokens",
|
||||
@@ -102,7 +126,6 @@ export const tracesTableCols: ColumnDefinition[] = [
|
||||
internal: 'generation_metrics."totalTokens"',
|
||||
nullable: true,
|
||||
},
|
||||
|
||||
{
|
||||
name: "Scores",
|
||||
id: "scores_avg",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
diff --git a/core/lib/oauth/client.js b/core/lib/oauth/client.js
|
||||
index 52c51eb6ff422dc0899ccec31baf3fa39e42eeae..bc50c35bb617d0e86b68ca42f64d44b475ba4abb 100644
|
||||
--- a/core/lib/oauth/client.js
|
||||
+++ b/core/lib/oauth/client.js
|
||||
@@ -1,5 +1,7 @@
|
||||
"use strict";
|
||||
|
||||
+var HttpsProxyAgent = require('https-proxy-agent').HttpsProxyAgent;
|
||||
+
|
||||
Object.defineProperty(exports, "__esModule", {
|
||||
value: true
|
||||
});
|
||||
@@ -7,7 +9,12 @@ exports.openidClient = openidClient;
|
||||
var _openidClient = require("openid-client");
|
||||
async function openidClient(options) {
|
||||
const provider = options.provider;
|
||||
- if (provider.httpOptions) _openidClient.custom.setHttpOptionsDefaults(provider.httpOptions);
|
||||
+ let httpOptions = {};
|
||||
+ if (provider.httpOptions) httpOptions = { ...provider.httpOptions };
|
||||
+ if (process.env.AUTH_HTTPS_PROXY || process.env.AUTH_HTTP_PROXY) {
|
||||
+ httpOptions.agent = new HttpsProxyAgent(process.env.AUTH_HTTPS_PROXY || process.env.AUTH_HTTP_PROXY);
|
||||
+ }
|
||||
+ _openidClient.custom.setHttpOptionsDefaults(httpOptions);
|
||||
let issuer;
|
||||
if (provider.wellKnown) {
|
||||
issuer = await _openidClient.Issuer.discover(provider.wellKnown);
|
||||
Generated
+352
-234
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "3.13.0",
|
||||
"version": "3.17.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -109,9 +109,11 @@
|
||||
"date-fns": "^3.3.1",
|
||||
"dd-trace": "^5.23.1",
|
||||
"decimal.js": "^10.4.3",
|
||||
"diff": "^7.0.0",
|
||||
"dompurify": "^3.1.5",
|
||||
"fastest-levenshtein": "^1.0.16",
|
||||
"graphql": "^16.9.0",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"ioredis": "^5.4.1",
|
||||
"ip-address": "^9.0.5",
|
||||
"kysely": "^0.27.4",
|
||||
@@ -158,6 +160,7 @@
|
||||
"@testing-library/react": "^15.0.7",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/diff": "^7.0.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/eslint": "^8.56.7",
|
||||
"@types/jest": "^29.5.12",
|
||||
|
||||
@@ -53,6 +53,7 @@ components:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
nullable: true
|
||||
traceId:
|
||||
type: string
|
||||
example: cdef-1234-5678-90ab
|
||||
@@ -67,15 +68,19 @@ components:
|
||||
values must equal either 1 or 0 (true or false)
|
||||
observationId:
|
||||
type: string
|
||||
nullable: true
|
||||
comment:
|
||||
type: string
|
||||
nullable: true
|
||||
dataType:
|
||||
$ref: '#/components/schemas/ScoreDataType'
|
||||
nullable: true
|
||||
description: >-
|
||||
When set, must match the score value's type. If not set, will be
|
||||
inferred from the score value or config
|
||||
configId:
|
||||
type: string
|
||||
nullable: true
|
||||
description: >-
|
||||
Reference a score config on a score. When set, the score name must
|
||||
equal the config name and scores must comply with the config's range
|
||||
@@ -100,6 +105,7 @@ components:
|
||||
$ref: '#/components/schemas/ScoreSource'
|
||||
observationId:
|
||||
type: string
|
||||
nullable: true
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -111,10 +117,13 @@ components:
|
||||
format: date-time
|
||||
authorUserId:
|
||||
type: string
|
||||
nullable: true
|
||||
comment:
|
||||
type: string
|
||||
nullable: true
|
||||
configId:
|
||||
type: string
|
||||
nullable: true
|
||||
description: >-
|
||||
Reference a score config on a score. When set, config and score name
|
||||
must be equal and value must comply to optionally defined numerical
|
||||
@@ -166,6 +175,7 @@ components:
|
||||
value:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
description: >-
|
||||
Only defined if a config is linked. Represents the numeric category
|
||||
mapping of the stringValue
|
||||
|
||||
@@ -737,7 +737,7 @@ paths:
|
||||
- Ingestion
|
||||
parameters: []
|
||||
responses:
|
||||
'200':
|
||||
'207':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
@@ -1426,6 +1426,76 @@ paths:
|
||||
schema: {}
|
||||
security:
|
||||
- BasicAuth: []
|
||||
/api/public/v2/prompts/{promptName}/version/{version}:
|
||||
patch:
|
||||
description: Update labels for a specific prompt version
|
||||
operationId: promptVersion_update
|
||||
tags:
|
||||
- PromptVersion
|
||||
parameters:
|
||||
- name: promptName
|
||||
in: path
|
||||
description: The name of the prompt
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: version
|
||||
in: path
|
||||
description: Version of the prompt to update
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Prompt'
|
||||
'400':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security:
|
||||
- BasicAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
newLabels:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: >-
|
||||
New labels for the prompt version. Labels are unique across
|
||||
versions. The "latest" label is reserved and managed by
|
||||
Langfuse.
|
||||
required:
|
||||
- newLabels
|
||||
/api/public/v2/prompts/{promptName}:
|
||||
get:
|
||||
description: Get a prompt
|
||||
|
||||
@@ -1057,6 +1057,61 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "container",
|
||||
"description": null,
|
||||
"name": "Prompt Version",
|
||||
"item": [
|
||||
{
|
||||
"_type": "endpoint",
|
||||
"name": "Update",
|
||||
"request": {
|
||||
"description": "Update labels for a specific prompt version",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/v2/prompts/:promptName/version/:version",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"public",
|
||||
"v2",
|
||||
"prompts",
|
||||
":promptName",
|
||||
"version",
|
||||
":version"
|
||||
],
|
||||
"query": [],
|
||||
"variable": [
|
||||
{
|
||||
"key": "promptName",
|
||||
"value": "",
|
||||
"description": "The name of the prompt"
|
||||
},
|
||||
{
|
||||
"key": "version",
|
||||
"value": "",
|
||||
"description": "Version of the prompt to update"
|
||||
}
|
||||
]
|
||||
},
|
||||
"header": [],
|
||||
"method": "PATCH",
|
||||
"auth": null,
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"newLabels\": [\n \"example\"\n ]\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "container",
|
||||
"description": null,
|
||||
|
||||
@@ -312,4 +312,52 @@ describe("Clickhouse Traces Repository Test", () => {
|
||||
const exists = await checkTraceExists(projectId, traceId, new Date(), []);
|
||||
expect(exists).toBe(true);
|
||||
});
|
||||
it("should check if trace exists with error level count > 0", async () => {
|
||||
const traceId = v4();
|
||||
const trace = createTrace({
|
||||
id: traceId,
|
||||
user_id: "user-1",
|
||||
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
});
|
||||
|
||||
const observations = [
|
||||
createObservation({
|
||||
trace_id: trace.id,
|
||||
project_id: trace.project_id,
|
||||
name: "observation-name",
|
||||
end_time: new Date().getTime(),
|
||||
start_time: new Date().getTime() - 1000,
|
||||
input: "input",
|
||||
output: "output",
|
||||
provided_model_name: "model-1",
|
||||
level: "ERROR",
|
||||
}),
|
||||
createObservation({
|
||||
trace_id: trace.id,
|
||||
project_id: trace.project_id,
|
||||
name: "observation-name-2",
|
||||
end_time: new Date().getTime(),
|
||||
start_time: new Date().getTime() - 100000,
|
||||
input: "input-2",
|
||||
output: "output-2",
|
||||
provided_model_name: "model-2",
|
||||
}),
|
||||
];
|
||||
|
||||
await createTracesCh([trace]);
|
||||
await createObservationsCh(observations);
|
||||
|
||||
const exists = await checkTraceExists(projectId, traceId, new Date(), [
|
||||
{
|
||||
type: "number",
|
||||
column: "errorCount",
|
||||
operator: ">",
|
||||
value: 0,
|
||||
},
|
||||
]);
|
||||
expect(exists).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -271,4 +271,76 @@ describe("/api/public/traces API Endpoint", () => {
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("LFE-3699: should fetch a single trace with unescaped metadata via traces list", async () => {
|
||||
const traceId = randomUUID();
|
||||
const trace = createTrace({
|
||||
id: traceId,
|
||||
name: "trace-name1",
|
||||
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
metadata: { key: JSON.stringify({ foo: "bar" }) },
|
||||
input: JSON.stringify({
|
||||
args: [
|
||||
{
|
||||
foo: "bar",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
await createTracesCh([trace]);
|
||||
|
||||
const traces = await makeZodVerifiedAPICall(
|
||||
GetTracesV1Response,
|
||||
"GET",
|
||||
`/api/public/traces`,
|
||||
);
|
||||
|
||||
const traceResponse = traces.body.data.find((t) => t.id === traceId);
|
||||
expect(traceResponse).toBeDefined();
|
||||
expect(traceResponse!.name).toBe("trace-name1");
|
||||
expect(traceResponse!.metadata).toEqual({ key: { foo: "bar" } });
|
||||
expect(traceResponse!.input).toEqual({
|
||||
args: [
|
||||
{
|
||||
foo: "bar",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("LFE-3699: should fetch a single trace with unescaped metadata via single trace endpoint", async () => {
|
||||
const traceId = randomUUID();
|
||||
const trace = createTrace({
|
||||
id: traceId,
|
||||
name: "trace-name1",
|
||||
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
metadata: { key: JSON.stringify({ foo: "bar" }) },
|
||||
input: JSON.stringify({
|
||||
args: [
|
||||
{
|
||||
foo: "bar",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
await createTracesCh([trace]);
|
||||
|
||||
const traceResponse = await makeZodVerifiedAPICall(
|
||||
GetTraceV1Response,
|
||||
"GET",
|
||||
`/api/public/traces/${traceId}`,
|
||||
);
|
||||
|
||||
expect(traceResponse.body.name).toBe("trace-name1");
|
||||
expect(traceResponse.body.metadata).toEqual({ key: { foo: "bar" } });
|
||||
expect(traceResponse.body.input).toEqual({
|
||||
args: [
|
||||
{
|
||||
foo: "bar",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,10 @@ import {
|
||||
import { nanoid } from "ai";
|
||||
|
||||
import { type PromptsMetaResponse } from "@/src/features/prompts/server/actions/getPromptsMeta";
|
||||
import { getObservationById } from "@langfuse/shared/src/server";
|
||||
import {
|
||||
createOrgProjectAndApiKey,
|
||||
getObservationById,
|
||||
} from "@langfuse/shared/src/server";
|
||||
|
||||
const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
|
||||
const baseURI = "/api/public/v2/prompts";
|
||||
@@ -1113,6 +1116,151 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH api/public/v2/prompts/[promptName]/version/[version]", () => {
|
||||
it("should update the labels of a prompt", async () => {
|
||||
const { projectId: newProjectId, auth: newAuth } =
|
||||
await createOrgProjectAndApiKey();
|
||||
|
||||
const originalPrompt = await prisma.prompt.create({
|
||||
data: {
|
||||
name: "prompt-1",
|
||||
projectId: newProjectId,
|
||||
version: 1,
|
||||
labels: ["production"],
|
||||
createdBy: "user-test",
|
||||
prompt: "prompt-1",
|
||||
},
|
||||
});
|
||||
|
||||
const response = await makeAPICall(
|
||||
"PATCH",
|
||||
`${baseURI}/prompt-1/version/1`,
|
||||
{
|
||||
newLabels: ["new-label"],
|
||||
},
|
||||
newAuth,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const updatedPrompt = await prisma.prompt.findUnique({
|
||||
where: {
|
||||
id: originalPrompt.id,
|
||||
},
|
||||
});
|
||||
expect(updatedPrompt?.labels).toContain("production");
|
||||
expect(updatedPrompt?.labels).toContain("new-label");
|
||||
expect(updatedPrompt?.labels).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should remove label from previous version when adding to new version", async () => {
|
||||
const { projectId: newProjectId, auth: newAuth } =
|
||||
await createOrgProjectAndApiKey();
|
||||
|
||||
// Create version 1 with "production" label
|
||||
await prisma.prompt.create({
|
||||
data: {
|
||||
name: "prompt-1",
|
||||
projectId: newProjectId,
|
||||
version: 1,
|
||||
labels: ["production"],
|
||||
createdBy: "user-test",
|
||||
prompt: "prompt-1",
|
||||
},
|
||||
});
|
||||
|
||||
// Create version 2 initially without the label
|
||||
await prisma.prompt.create({
|
||||
data: {
|
||||
name: "prompt-1",
|
||||
projectId: newProjectId,
|
||||
version: 2,
|
||||
labels: [],
|
||||
createdBy: "user-test",
|
||||
prompt: "prompt-1",
|
||||
},
|
||||
});
|
||||
|
||||
// Add "production" label to version 2
|
||||
const response = await makeAPICall(
|
||||
"PATCH",
|
||||
`${baseURI}/prompt-1/version/2`,
|
||||
{
|
||||
newLabels: ["production"],
|
||||
},
|
||||
newAuth,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const responseBody = response.body as unknown as Prompt;
|
||||
expect(responseBody.labels).toEqual(["production"]);
|
||||
|
||||
// Check version 2 got the label
|
||||
const promptV2 = await prisma.prompt.findFirst({
|
||||
where: {
|
||||
projectId: newProjectId,
|
||||
name: "prompt-1",
|
||||
version: 2,
|
||||
},
|
||||
});
|
||||
expect(promptV2?.labels).toEqual(["production"]);
|
||||
|
||||
// Check version 1 had the label removed
|
||||
const promptV1 = await prisma.prompt.findFirst({
|
||||
where: {
|
||||
projectId: newProjectId,
|
||||
name: "prompt-1",
|
||||
version: 1,
|
||||
},
|
||||
});
|
||||
expect(promptV1?.labels).toEqual([]);
|
||||
});
|
||||
|
||||
it("trying to set 'latest' label results in 400 error", async () => {
|
||||
const { projectId: newProjectId, auth: newAuth } =
|
||||
await createOrgProjectAndApiKey();
|
||||
// Create initial prompt version
|
||||
await prisma.prompt.create({
|
||||
data: {
|
||||
name: "prompt-1",
|
||||
projectId: newProjectId,
|
||||
version: 1,
|
||||
labels: [],
|
||||
createdBy: "user-test",
|
||||
prompt: "prompt-1",
|
||||
},
|
||||
});
|
||||
|
||||
// Try to set "latest" label
|
||||
const response = await makeAPICall(
|
||||
"PATCH",
|
||||
`${baseURI}/prompt-1/version/1`,
|
||||
{
|
||||
newLabels: ["latest"],
|
||||
},
|
||||
newAuth,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("updating non existing prompt results in 404", async () => {
|
||||
const { auth: newAuth } = await createOrgProjectAndApiKey();
|
||||
|
||||
// Try to update non-existing prompt
|
||||
const response = await makeAPICall(
|
||||
"PATCH",
|
||||
`${baseURI}/non-existing-prompt/version/1`,
|
||||
{
|
||||
newLabels: ["production"],
|
||||
},
|
||||
newAuth,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
const isPrompt = (x: unknown): x is Prompt => {
|
||||
if (typeof x !== "object" || x === null) return false;
|
||||
const prompt = x as Prompt;
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Card, CardContent } from "@/src/components/ui/card";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { diffChars } from "diff";
|
||||
|
||||
type DiffLine = {
|
||||
text: string;
|
||||
type: "unchanged" | "removed" | "added" | "empty";
|
||||
parts?: {
|
||||
value: string;
|
||||
type?: "removed" | "added";
|
||||
}[];
|
||||
};
|
||||
|
||||
type DiffViewerProps = {
|
||||
oldString: string;
|
||||
newString: string;
|
||||
oldLabel?: string;
|
||||
newLabel?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const DIFF_COLORS = {
|
||||
added: {
|
||||
text: "bg-green-500/30",
|
||||
line: "bg-green-500/10",
|
||||
},
|
||||
removed: {
|
||||
text: "bg-destructive/60",
|
||||
line: "bg-destructive/10",
|
||||
},
|
||||
empty: "bg-muted",
|
||||
} as const;
|
||||
|
||||
const DiffViewer: React.FC<DiffViewerProps> = ({
|
||||
oldString,
|
||||
newString,
|
||||
oldLabel = "Original Version",
|
||||
newLabel = "New Version",
|
||||
className,
|
||||
}) => {
|
||||
const [diffLines, setDiffLines] = useState<{
|
||||
left: DiffLine[];
|
||||
right: DiffLine[];
|
||||
}>({ left: [], right: [] });
|
||||
|
||||
useEffect(() => {
|
||||
const left: DiffLine[] = [];
|
||||
const right: DiffLine[] = [];
|
||||
|
||||
// Get the complete diff first
|
||||
const changes = diffChars(oldString, newString, {});
|
||||
|
||||
// Group changes by line
|
||||
const oldParts: { value: string; type?: "removed" }[][] = [[]];
|
||||
const newParts: { value: string; type?: "added" }[][] = [[]];
|
||||
|
||||
changes.forEach((part) => {
|
||||
const lines = part.value.split("\n");
|
||||
|
||||
lines.forEach((line, idx) => {
|
||||
if (idx > 0) {
|
||||
if (!part.added) oldParts.push([]);
|
||||
if (!part.removed) newParts.push([]);
|
||||
}
|
||||
|
||||
if (!part.added) {
|
||||
oldParts[oldParts.length - 1].push({
|
||||
value: line,
|
||||
type: part.removed ? "removed" : undefined,
|
||||
});
|
||||
}
|
||||
if (!part.removed) {
|
||||
newParts[newParts.length - 1].push({
|
||||
value: line,
|
||||
type: part.added ? "added" : undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Convert parts to DiffLines
|
||||
const maxLength = Math.max(oldParts.length, newParts.length);
|
||||
for (let i = 0; i < maxLength; i++) {
|
||||
const oldLineParts = oldParts[i] || [];
|
||||
const newLineParts = newParts[i] || [];
|
||||
|
||||
if (oldLineParts.length === 0 && newLineParts.length === 0) {
|
||||
left.push({ text: "", type: "empty" });
|
||||
right.push({ text: "", type: "empty" });
|
||||
continue;
|
||||
}
|
||||
|
||||
const oldText = oldLineParts.map((p) => p.value).join("");
|
||||
const newText = newLineParts.map((p) => p.value).join("");
|
||||
|
||||
if (oldText === newText) {
|
||||
left.push({ text: oldText, type: "unchanged" });
|
||||
right.push({ text: newText, type: "unchanged" });
|
||||
} else {
|
||||
left.push({
|
||||
text: oldText,
|
||||
type: oldText ? "removed" : "empty",
|
||||
parts: oldLineParts.length ? oldLineParts : undefined,
|
||||
});
|
||||
right.push({
|
||||
text: newText,
|
||||
type: newText ? "added" : "empty",
|
||||
parts: newLineParts.length ? newLineParts : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setDiffLines({ left, right });
|
||||
}, [oldString, newString]);
|
||||
|
||||
const DiffRow: React.FC<{ leftLine: DiffLine; rightLine: DiffLine }> = ({
|
||||
leftLine,
|
||||
rightLine,
|
||||
}) => {
|
||||
const typeClasses = {
|
||||
unchanged: "",
|
||||
removed: DIFF_COLORS.removed.line,
|
||||
added: DIFF_COLORS.added.line,
|
||||
empty: DIFF_COLORS.empty,
|
||||
};
|
||||
|
||||
const renderContent = (line: DiffLine) =>
|
||||
line.parts
|
||||
? line.parts.map((part, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className={part.type ? DIFF_COLORS[part.type].text : undefined}
|
||||
>
|
||||
{part.value}
|
||||
</span>
|
||||
))
|
||||
: line.text || "\u00A0";
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2">
|
||||
<div
|
||||
className={cn(
|
||||
"whitespace-pre-wrap break-words border-r px-4 py-1 font-mono text-xs",
|
||||
typeClasses[leftLine.type],
|
||||
)}
|
||||
>
|
||||
{renderContent(leftLine)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"whitespace-pre-wrap break-words px-4 py-1 font-mono text-xs",
|
||||
typeClasses[rightLine.type],
|
||||
)}
|
||||
>
|
||||
{renderContent(rightLine)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (oldString === newString) {
|
||||
return <div className="text-sm text-muted-foreground">No changes</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="border-b border-r bg-muted px-4 py-2 text-xs font-semibold">
|
||||
{oldLabel}
|
||||
</div>
|
||||
<div className="border-b bg-muted px-4 py-2 text-xs font-semibold">
|
||||
{newLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{diffLines.left.map((leftLine, idx) => (
|
||||
<DiffRow
|
||||
key={idx}
|
||||
leftLine={leftLine}
|
||||
rightLine={diffLines.right[idx]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiffViewer;
|
||||
@@ -13,10 +13,12 @@ export function JsonEditor({
|
||||
editable = true,
|
||||
lineWrapping = true,
|
||||
className,
|
||||
onBlur,
|
||||
}: {
|
||||
defaultValue: string;
|
||||
onChange?: (value: string) => void;
|
||||
editable?: boolean;
|
||||
onBlur?: () => void;
|
||||
lineWrapping?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
@@ -45,6 +47,7 @@ export function JsonEditor({
|
||||
if (onChange) onChange(c);
|
||||
setLinterEnabled(c !== "");
|
||||
}}
|
||||
onBlur={onBlur}
|
||||
className={cn("overflow-hidden rounded-md border text-xs", className)}
|
||||
editable={editable}
|
||||
/>
|
||||
|
||||
@@ -68,7 +68,17 @@ function useSessionWithRetryOnUnauthenticated() {
|
||||
useEffect(() => {
|
||||
if (session.status === "unauthenticated" && retryCount < MAX_RETRIES) {
|
||||
const fetchSession = async () => {
|
||||
await getSession({ broadcast: true });
|
||||
try {
|
||||
await getSession({ broadcast: true });
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Error fetching session:",
|
||||
error,
|
||||
"\nError details:",
|
||||
JSON.stringify(error, null, 2),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
setRetryCount((prevCount) => prevCount + 1);
|
||||
};
|
||||
fetchSession();
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import { type ObservationLevel } from "@langfuse/shared";
|
||||
|
||||
export const LevelColors = {
|
||||
DEFAULT: { text: "", bg: "" },
|
||||
DEBUG: { text: "text-muted-foreground", bg: "bg-primary-foreground" },
|
||||
WARNING: { text: "text-dark-yellow", bg: "bg-light-yellow" },
|
||||
ERROR: { text: "text-dark-red", bg: "bg-light-red" },
|
||||
};
|
||||
|
||||
export const LevelSymbols = {
|
||||
DEFAULT: "ℹ️",
|
||||
DEBUG: "🔍",
|
||||
WARNING: "⚠️",
|
||||
ERROR: "🚨",
|
||||
};
|
||||
|
||||
export const formatAsLabel = (countLabel: string) => {
|
||||
return countLabel.replace(/Count$/, "").toUpperCase() as ObservationLevel;
|
||||
};
|
||||
|
||||
@@ -24,7 +24,11 @@ import {
|
||||
import type Decimal from "decimal.js";
|
||||
import { numberFormatter, usdFormatter } from "@/src/utils/numbers";
|
||||
import { DeleteButton } from "@/src/components/deleteButton";
|
||||
import { LevelColors } from "@/src/components/level-colors";
|
||||
import {
|
||||
formatAsLabel,
|
||||
LevelColors,
|
||||
LevelSymbols,
|
||||
} from "@/src/components/level-colors";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { useDetailPageLists } from "@/src/features/navigate-detail-pages/context";
|
||||
import { useOrderByState } from "@/src/features/orderBy/hooks/useOrderByState";
|
||||
@@ -52,6 +56,8 @@ import { BatchExportTableButton } from "@/src/components/BatchExportTableButton"
|
||||
import { BreakdownTooltip } from "@/src/components/trace/BreakdownToolTip";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { useHasEntitlement } from "@/src/features/entitlements/hooks";
|
||||
import { Separator } from "@/src/components/ui/separator";
|
||||
import React from "react";
|
||||
|
||||
export type TracesTableRow = {
|
||||
bookmarked: boolean;
|
||||
@@ -61,6 +67,12 @@ export type TracesTableRow = {
|
||||
userId: string;
|
||||
level?: ObservationLevel;
|
||||
observationCount?: bigint;
|
||||
levelCounts: {
|
||||
errorCount?: bigint;
|
||||
warningCount?: bigint;
|
||||
debugCount?: bigint;
|
||||
defaultCount?: bigint;
|
||||
};
|
||||
// scores holds grouped column with individual scores
|
||||
scores?: ScoreAggregate;
|
||||
latency?: number;
|
||||
@@ -629,6 +641,40 @@ export default function TracesTable({
|
||||
defaultHidden: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "levelCounts",
|
||||
id: "levelCounts",
|
||||
header: "Observation Levels",
|
||||
size: 75,
|
||||
cell: ({ row }) => {
|
||||
const value: TracesTableRow["levelCounts"] =
|
||||
row.getValue("levelCounts");
|
||||
if (!traceMetrics.data) return <Skeleton className="h-3 w-1/2" />;
|
||||
|
||||
const nonZeroCounts = Object.entries(value).filter(
|
||||
([_, count]) => count > 0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-6 flex-row gap-2 overflow-x-auto whitespace-nowrap">
|
||||
{nonZeroCounts.map(([level, count], index) => (
|
||||
<React.Fragment key={level}>
|
||||
<div className="flex min-w-6 flex-row gap-2">
|
||||
<span className="text-xs">
|
||||
{LevelSymbols[formatAsLabel(level)]}{" "}
|
||||
{numberFormatter(count, 0)}
|
||||
</span>
|
||||
</div>
|
||||
{index < nonZeroCounts.length - 1 && (
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableHiding: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "observationCount",
|
||||
id: "observationCount",
|
||||
@@ -754,6 +800,12 @@ export default function TracesTable({
|
||||
completionTokens: trace.completionTokens,
|
||||
totalTokens: trace.totalTokens,
|
||||
},
|
||||
levelCounts: {
|
||||
errorCount: trace.errorCount,
|
||||
warningCount: trace.warningCount,
|
||||
defaultCount: trace.defaultCount,
|
||||
debugCount: trace.debugCount,
|
||||
},
|
||||
usageDetails: trace.usageDetails,
|
||||
costDetails: trace.costDetails,
|
||||
scores: trace.scores
|
||||
|
||||
@@ -76,6 +76,7 @@ function TreeItemInner({
|
||||
totalScaleSpan,
|
||||
type,
|
||||
startOffset = 0,
|
||||
firstTokenTimeOffset,
|
||||
name,
|
||||
children,
|
||||
setBackgroundColor,
|
||||
@@ -86,6 +87,7 @@ function TreeItemInner({
|
||||
totalScaleSpan: number;
|
||||
type: TreeItemType;
|
||||
startOffset?: number;
|
||||
firstTokenTimeOffset?: number;
|
||||
name?: string | null;
|
||||
children?: React.ReactNode;
|
||||
setBackgroundColor: (color: string) => void;
|
||||
@@ -147,31 +149,80 @@ function TreeItemInner({
|
||||
<div className="flex items-center" style={{ width: `${SCALE_WIDTH}px` }}>
|
||||
<div className={`relative w-[${SCALE_WIDTH}px]`}>
|
||||
<div className="ml-4 mr-4 h-full border-r-2"></div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-5 items-center justify-end rounded-sm",
|
||||
itemWidth
|
||||
? treeItemColors.get(type)
|
||||
: "border border-dashed bg-muted",
|
||||
)}
|
||||
style={{
|
||||
width: `${itemWidth || 10}px`,
|
||||
marginLeft: `${startOffset}px`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
{firstTokenTimeOffset ? (
|
||||
<div className="flex" style={{ marginLeft: `${startOffset}px` }}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-5 items-center justify-end rounded-l-sm border-r border-gray-400 opacity-60",
|
||||
itemWidth
|
||||
? treeItemColors.get(type)
|
||||
: "border border-dashed bg-muted",
|
||||
)}
|
||||
style={{
|
||||
width: `${firstTokenTimeOffset - startOffset}px`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text text-xs font-light italic text-foreground group-hover:block",
|
||||
firstTokenTimeOffset - startOffset < 30 ? "-mr-14" : "mr-2",
|
||||
)}
|
||||
>
|
||||
First token
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-5 items-center justify-end rounded-r-sm",
|
||||
itemWidth
|
||||
? treeItemColors.get(type)
|
||||
: "border border-dashed bg-muted",
|
||||
)}
|
||||
style={{
|
||||
width: `${itemWidth - (firstTokenTimeOffset - startOffset)}px`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"hidden justify-end text-xs text-muted-foreground group-hover:block",
|
||||
itemOffsetLabelWidth > SCALE_WIDTH
|
||||
? "mr-1"
|
||||
: isPresent(latency)
|
||||
? `-mr-9`
|
||||
: "-mr-6",
|
||||
)}
|
||||
>
|
||||
{isPresent(latency) ? `${latency.toFixed(2)}s` : "n/a"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"hidden justify-end text-xs text-muted-foreground group-hover:block",
|
||||
itemOffsetLabelWidth > SCALE_WIDTH
|
||||
? "mr-1"
|
||||
: isPresent(latency)
|
||||
? `-mr-9`
|
||||
: "-mr-6",
|
||||
"flex h-5 items-center justify-end rounded-sm",
|
||||
itemWidth
|
||||
? treeItemColors.get(type)
|
||||
: "border border-dashed bg-muted",
|
||||
)}
|
||||
style={{
|
||||
width: `${itemWidth || 10}px`,
|
||||
marginLeft: `${startOffset}px`,
|
||||
}}
|
||||
>
|
||||
{isPresent(latency) ? `${latency.toFixed(2)}s` : "n/a"}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"hidden justify-end text-xs text-muted-foreground group-hover:block",
|
||||
itemOffsetLabelWidth > SCALE_WIDTH
|
||||
? "mr-1"
|
||||
: isPresent(latency)
|
||||
? `-mr-9`
|
||||
: "-mr-6",
|
||||
)}
|
||||
>
|
||||
{isPresent(latency) ? `${latency.toFixed(2)}s` : "n/a"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -199,7 +250,7 @@ function TraceTreeItem({
|
||||
cardWidth: number;
|
||||
commentCounts?: Map<string, number>;
|
||||
}) {
|
||||
const { startTime, endTime } = observation || {};
|
||||
const { startTime, completionStartTime, endTime } = observation || {};
|
||||
const [backgroundColor, setBackgroundColor] = useState("");
|
||||
|
||||
const latency = endTime
|
||||
@@ -208,6 +259,12 @@ function TraceTreeItem({
|
||||
const startOffset =
|
||||
((startTime.getTime() - traceStartTime.getTime()) / totalScaleSpan / 1000) *
|
||||
SCALE_WIDTH;
|
||||
const firstTokenTimeOffset = completionStartTime
|
||||
? ((completionStartTime.getTime() - traceStartTime.getTime()) /
|
||||
totalScaleSpan /
|
||||
1000) *
|
||||
SCALE_WIDTH
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<TreeItem
|
||||
@@ -224,6 +281,7 @@ function TraceTreeItem({
|
||||
type={observation.type}
|
||||
name={observation.name}
|
||||
startOffset={startOffset}
|
||||
firstTokenTimeOffset={firstTokenTimeOffset}
|
||||
totalScaleSpan={totalScaleSpan}
|
||||
setBackgroundColor={setBackgroundColor}
|
||||
level={level}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const VERSION = "v3.13.0";
|
||||
export const VERSION = "v3.17.0";
|
||||
|
||||
@@ -21,12 +21,18 @@ import { Alert, AlertDescription, AlertTitle } from "@/src/components/ui/alert";
|
||||
import { MAX_EVENTS_FREE_PLAN } from "@/src/ee/features/billing/constants";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/src/components/ui/dialog";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { stripeProducts } from "@/src/ee/features/billing/utils/stripeProducts";
|
||||
import { toast } from "sonner";
|
||||
import { ActionButton } from "@/src/components/ActionButton";
|
||||
|
||||
export const BillingSettings = () => {
|
||||
const router = useRouter();
|
||||
@@ -163,18 +169,19 @@ const BillingPortalOrPricingPageButton = () => {
|
||||
router.push(url);
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) return null;
|
||||
if (billingPortalUrl.isLoading) return null;
|
||||
if (billingPortalUrl.data)
|
||||
return (
|
||||
<Button asChild>
|
||||
<Link href={billingPortalUrl.data}>Billing portal</Link>
|
||||
</Button>
|
||||
);
|
||||
const mutChangePlan =
|
||||
api.cloudBilling.changeStripeSubscriptionProduct.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Plan changed successfully");
|
||||
// wait 1 second before reloading
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 500);
|
||||
},
|
||||
});
|
||||
|
||||
// Do not show checkout or customer portal if manual plan is set in cloud config
|
||||
if (organization.cloudConfig?.plan) {
|
||||
if (organization?.cloudConfig?.plan) {
|
||||
if (chatAvailable)
|
||||
return (
|
||||
<Button
|
||||
@@ -191,8 +198,7 @@ const BillingPortalOrPricingPageButton = () => {
|
||||
else return null;
|
||||
}
|
||||
|
||||
// Show pricing page button
|
||||
return (
|
||||
const switchPlan = (
|
||||
<Dialog
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
@@ -230,22 +236,89 @@ const BillingPortalOrPricingPageButton = () => {
|
||||
</div>
|
||||
<div>{product.checkout?.description}</div>
|
||||
<div className="mb-6 mt-2">{product.checkout?.price}</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (organization)
|
||||
mutCreateCheckoutSession.mutate({
|
||||
orgId: organization.id,
|
||||
stripeProductId: product.stripeProductId,
|
||||
});
|
||||
}}
|
||||
className="mt-auto"
|
||||
>
|
||||
Select plan
|
||||
</Button>
|
||||
{organization?.cloudConfig?.stripe?.activeProductId ? (
|
||||
// Change plan
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
disabled={
|
||||
organization?.cloudConfig?.stripe?.activeProductId ===
|
||||
product.stripeProductId
|
||||
}
|
||||
className="mt-auto"
|
||||
>
|
||||
{organization?.cloudConfig?.stripe?.activeProductId ===
|
||||
product.stripeProductId
|
||||
? "Current plan"
|
||||
: "Change plan"}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirm Plan Change</DialogTitle>
|
||||
<DialogDescription>
|
||||
Changing your plan will immediately generate an
|
||||
invoice for any usage on your current plan. Your new
|
||||
plan and billing period will start today. Are you sure
|
||||
you want to continue?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary">Cancel</Button>
|
||||
</DialogClose>
|
||||
<ActionButton
|
||||
onClick={() => {
|
||||
if (organization) {
|
||||
mutChangePlan.mutate({
|
||||
orgId: organization.id,
|
||||
stripeProductId: product.stripeProductId,
|
||||
});
|
||||
}
|
||||
}}
|
||||
loading={mutChangePlan.isLoading}
|
||||
>
|
||||
Confirm
|
||||
</ActionButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
// Upgrade, no plan yet
|
||||
<ActionButton
|
||||
onClick={() => {
|
||||
if (organization)
|
||||
mutCreateCheckoutSession.mutate({
|
||||
orgId: organization.id,
|
||||
stripeProductId: product.stripeProductId,
|
||||
});
|
||||
}}
|
||||
disabled={
|
||||
organization?.cloudConfig?.stripe?.activeProductId ===
|
||||
product.stripeProductId
|
||||
}
|
||||
className="mt-auto"
|
||||
loading={mutCreateCheckoutSession.isLoading}
|
||||
>
|
||||
Select plan
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
// Show pricing page button
|
||||
return (
|
||||
<>
|
||||
{switchPlan}
|
||||
{billingPortalUrl.data && (
|
||||
<Button asChild>
|
||||
<Link href={billingPortalUrl.data}>Billing portal</Link>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -147,6 +147,130 @@ export const cloudBillingRouter = createTRPCRouter({
|
||||
|
||||
return session.url;
|
||||
}),
|
||||
changeStripeSubscriptionProduct: protectedOrganizationProcedure
|
||||
.input(
|
||||
z.object({
|
||||
orgId: z.string(),
|
||||
stripeProductId: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
throwIfNoOrganizationAccess({
|
||||
organizationId: input.orgId,
|
||||
scope: "langfuseCloudBilling:CRUD",
|
||||
session: ctx.session,
|
||||
});
|
||||
throwIfNoEntitlement({
|
||||
entitlement: "cloud-billing",
|
||||
sessionUser: ctx.session.user,
|
||||
orgId: input.orgId,
|
||||
});
|
||||
|
||||
// check that product is valid
|
||||
if (
|
||||
!stripeProducts
|
||||
.filter((i) => Boolean(i.checkout))
|
||||
.map((i) => i.stripeProductId)
|
||||
.includes(input.stripeProductId)
|
||||
)
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Invalid stripe product id, product not available",
|
||||
});
|
||||
|
||||
const org = await ctx.prisma.organization.findUnique({
|
||||
where: {
|
||||
id: input.orgId,
|
||||
},
|
||||
});
|
||||
if (!org) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Organization not found",
|
||||
});
|
||||
}
|
||||
|
||||
const parsedOrg = parseDbOrg(org);
|
||||
if (parsedOrg.cloudConfig?.plan)
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Cannot change plan for orgs that have a manual/legacy plan",
|
||||
});
|
||||
|
||||
const stripeSubscriptionId =
|
||||
parsedOrg.cloudConfig?.stripe?.activeSubscriptionId;
|
||||
|
||||
if (!stripeSubscriptionId)
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Organization does not have an active subscription",
|
||||
});
|
||||
|
||||
if (!stripeClient)
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Stripe client not initialized",
|
||||
});
|
||||
|
||||
const subscription =
|
||||
await stripeClient.subscriptions.retrieve(stripeSubscriptionId);
|
||||
|
||||
if (
|
||||
["canceled", "paused", "incomplete", "incomplete_expired"].includes(
|
||||
subscription.status,
|
||||
)
|
||||
)
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message:
|
||||
"Subscription is not active, current status: " +
|
||||
subscription.status,
|
||||
});
|
||||
|
||||
if (subscription.items.data.length !== 1)
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Subscription has multiple items",
|
||||
});
|
||||
|
||||
const item = subscription.items.data[0];
|
||||
|
||||
if (
|
||||
!stripeProducts
|
||||
.map((i) => i.stripeProductId)
|
||||
.includes(item.price.product as string)
|
||||
)
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Current subscription product is not a valid product",
|
||||
});
|
||||
|
||||
const newProduct = await stripeClient.products.retrieve(
|
||||
input.stripeProductId,
|
||||
);
|
||||
if (!newProduct.default_price)
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "New product does not have a default price in Stripe",
|
||||
});
|
||||
|
||||
await stripeClient.subscriptions.update(stripeSubscriptionId, {
|
||||
items: [
|
||||
// remove current product from subscription
|
||||
{
|
||||
id: item.id,
|
||||
deleted: true,
|
||||
},
|
||||
// add new product to subscription
|
||||
{
|
||||
price: newProduct.default_price as string,
|
||||
},
|
||||
],
|
||||
// reset billing cycle which causes immediate invoice for existing plan
|
||||
billing_cycle_anchor: "now",
|
||||
proration_behavior: "none",
|
||||
});
|
||||
}),
|
||||
getStripeCustomerPortalUrl: protectedOrganizationProcedure
|
||||
.input(
|
||||
z.object({
|
||||
|
||||
@@ -69,7 +69,7 @@ import { showSuccessToast } from "@/src/features/notifications/showSuccessToast"
|
||||
const formSchema = z.object({
|
||||
scoreName: z.string(),
|
||||
target: z.string(),
|
||||
filter: z.array(singleFilter).nullable(), // re-using the filter type from the tables
|
||||
filter: z.array(singleFilter).nullable(), // reusing the filter type from the tables
|
||||
mapping: z.array(wipVariableMapping),
|
||||
sampling: z.coerce.number().gt(0).lte(1),
|
||||
delay: z.coerce.number().optional().default(10),
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { decrypt } from "@langfuse/shared/encryption";
|
||||
import { throwIfNoEntitlement } from "@/src/features/entitlements/server/hasEntitlement";
|
||||
import {
|
||||
decryptAndParseExtraHeaders,
|
||||
fetchLLMCompletion,
|
||||
getScoresByIds,
|
||||
LLMApiKeySchema,
|
||||
@@ -37,7 +38,7 @@ const APIEvaluatorSchema = z.object({
|
||||
evalTemplateId: z.string(),
|
||||
scoreName: z.string(),
|
||||
targetObject: z.string(),
|
||||
filter: z.array(singleFilter).nullable(), // re-using the filter type from the tables
|
||||
filter: z.array(singleFilter).nullable(), // reusing the filter type from the tables
|
||||
variableMapping: z.array(variableMapping),
|
||||
sampling: z.instanceof(Prisma.Decimal),
|
||||
delay: z.number(),
|
||||
@@ -93,7 +94,7 @@ const CreateEvalJobSchema = z.object({
|
||||
evalTemplateId: z.string(),
|
||||
scoreName: z.string().min(1),
|
||||
target: z.string(),
|
||||
filter: z.array(singleFilter).nullable(), // re-using the filter type from the tables
|
||||
filter: z.array(singleFilter).nullable(), // reusing the filter type from the tables
|
||||
mapping: z.array(variableMapping),
|
||||
sampling: z.number().gt(0).lte(1),
|
||||
delay: z.number().gte(0).default(DEFAULT_TRACE_JOB_DELAY), // 10 seconds default
|
||||
@@ -536,6 +537,9 @@ export const evalRouter = createTRPCRouter({
|
||||
await fetchLLMCompletion({
|
||||
streaming: false,
|
||||
apiKey: decrypt(parsedKey.data.secretKey), // decrypt the secret key
|
||||
extraHeaders: decryptAndParseExtraHeaders(
|
||||
parsedKey.data.extraHeaders,
|
||||
),
|
||||
baseURL: parsedKey.data.baseURL ?? undefined,
|
||||
messages: [
|
||||
{
|
||||
|
||||
@@ -29,17 +29,17 @@ export const GithubProviderSchema = base.extend({
|
||||
});
|
||||
|
||||
export const GithubEnterpriseProviderSchema = base.extend({
|
||||
authProvider: z.literal("github-enterprise"),
|
||||
authConfig: z
|
||||
.object({
|
||||
clientId: z.string(),
|
||||
clientSecret: z.string(),
|
||||
enterprise: z.object({
|
||||
baseUrl: z.string().url(),
|
||||
}),
|
||||
allowDangerousEmailAccountLinking: z.boolean().optional().default(false),
|
||||
})
|
||||
.nullish(),
|
||||
authProvider: z.literal("github-enterprise"),
|
||||
authConfig: z
|
||||
.object({
|
||||
clientId: z.string(),
|
||||
clientSecret: z.string(),
|
||||
enterprise: z.object({
|
||||
baseUrl: z.string().url(),
|
||||
}),
|
||||
allowDangerousEmailAccountLinking: z.boolean().optional().default(false),
|
||||
})
|
||||
.nullish(),
|
||||
});
|
||||
|
||||
export const GitlabProviderSchema = base.extend({
|
||||
@@ -123,6 +123,7 @@ export const CustomProviderSchema = base.extend({
|
||||
clientSecret: z.string(),
|
||||
issuer: z.string(),
|
||||
scope: z.string().nullish(),
|
||||
idToken: z.boolean().optional().default(true),
|
||||
allowDangerousEmailAccountLinking: z.boolean().optional().default(false),
|
||||
})
|
||||
.nullish(),
|
||||
@@ -130,7 +131,9 @@ export const CustomProviderSchema = base.extend({
|
||||
|
||||
export type GoogleProviderSchema = z.infer<typeof GoogleProviderSchema>;
|
||||
export type GithubProviderSchema = z.infer<typeof GithubProviderSchema>;
|
||||
export type GithubEnterpriseProviderSchema = z.infer<typeof GithubEnterpriseProviderSchema>;
|
||||
export type GithubEnterpriseProviderSchema = z.infer<
|
||||
typeof GithubEnterpriseProviderSchema
|
||||
>;
|
||||
export type GitlabProviderSchema = z.infer<typeof GitlabProviderSchema>;
|
||||
export type Auth0ProviderSchema = z.infer<typeof Auth0ProviderSchema>;
|
||||
export type OktaProviderSchema = z.infer<typeof OktaProviderSchema>;
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
LLMApiKeySchema,
|
||||
logger,
|
||||
fetchLLMCompletion,
|
||||
decryptAndParseExtraHeaders,
|
||||
} from "@langfuse/shared/src/server";
|
||||
|
||||
export default async function chatCompletionHandler(req: NextRequest) {
|
||||
@@ -51,6 +52,7 @@ export default async function chatCompletionHandler(req: NextRequest) {
|
||||
streaming: true,
|
||||
callbacks: [new PosthogCallbackHandler("playground", body, userId)],
|
||||
apiKey: decrypt(parsedKey.data.secretKey),
|
||||
extraHeaders: decryptAndParseExtraHeaders(parsedKey.data.extraHeaders),
|
||||
baseURL: parsedKey.data.baseURL || undefined,
|
||||
config: parsedKey.data.config,
|
||||
});
|
||||
|
||||
+66
-11
@@ -1,6 +1,26 @@
|
||||
import { z } from "zod";
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
|
||||
const zAuthMethod = z
|
||||
.enum([
|
||||
"client_secret_basic",
|
||||
"client_secret_post",
|
||||
"client_secret_jwt",
|
||||
"private_key_jwt",
|
||||
"tls_client_auth",
|
||||
"self_signed_tls_client_auth",
|
||||
"none",
|
||||
])
|
||||
.optional()
|
||||
.default("client_secret_basic");
|
||||
|
||||
|
||||
const zAuthChecks = z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((s) => s?.split(",").map((s) => s.trim()))
|
||||
.pipe(z.array(z.enum(["nonce", "none", "pkce", "state"])).optional());
|
||||
|
||||
export const env = createEnv({
|
||||
/**
|
||||
* Specify your server-side environment variables schema here. This way you can ensure the app
|
||||
@@ -58,56 +78,66 @@ export const env = createEnv({
|
||||
AUTH_GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||||
AUTH_GOOGLE_ALLOWED_DOMAINS: z.string().optional(),
|
||||
AUTH_GOOGLE_ALLOW_ACCOUNT_LINKING: z.enum(["true", "false"]).optional(),
|
||||
AUTH_GOOGLE_CLIENT_AUTH_METHOD: zAuthMethod,
|
||||
AUTH_GOOGLE_CHECKS: zAuthChecks,
|
||||
AUTH_GITHUB_CLIENT_ID: z.string().optional(),
|
||||
AUTH_GITHUB_CLIENT_SECRET: z.string().optional(),
|
||||
AUTH_GITHUB_ALLOW_ACCOUNT_LINKING: z.enum(["true", "false"]).optional(),
|
||||
AUTH_GITHUB_CLIENT_AUTH_METHOD: zAuthMethod,
|
||||
AUTH_GITHUB_CHECKS: zAuthChecks,
|
||||
AUTH_GITHUB_ENTERPRISE_CLIENT_ID: z.string().optional(),
|
||||
AUTH_GITHUB_ENTERPRISE_CLIENT_SECRET: z.string().optional(),
|
||||
AUTH_GITHUB_ENTERPRISE_BASE_URL: z.string().optional(),
|
||||
AUTH_GITHUB_ENTERPRISE_ALLOW_ACCOUNT_LINKING: z
|
||||
.enum(["true", "false"])
|
||||
.optional(),
|
||||
AUTH_GITHUB_ENTERPRISE_CLIENT_AUTH_METHOD: zAuthMethod,
|
||||
AUTH_GITHUB_ENTERPRISE_CHECKS: zAuthChecks,
|
||||
AUTH_GITLAB_CLIENT_ID: z.string().optional(),
|
||||
AUTH_GITLAB_CLIENT_SECRET: z.string().optional(),
|
||||
AUTH_GITLAB_ALLOW_ACCOUNT_LINKING: z.enum(["true", "false"]).optional(),
|
||||
AUTH_GITLAB_ISSUER: z.string().optional(),
|
||||
AUTH_GITLAB_CLIENT_AUTH_METHOD: zAuthMethod,
|
||||
AUTH_GITLAB_CHECKS: zAuthChecks,
|
||||
AUTH_AZURE_AD_CLIENT_ID: z.string().optional(),
|
||||
AUTH_AZURE_AD_CLIENT_SECRET: z.string().optional(),
|
||||
AUTH_AZURE_AD_TENANT_ID: z.string().optional(),
|
||||
AUTH_AZURE_ALLOW_ACCOUNT_LINKING: z.enum(["true", "false"]).optional(),
|
||||
AUTH_AZURE_CLIENT_AUTH_METHOD: zAuthMethod,
|
||||
AUTH_AZURE_CHECKS: zAuthChecks,
|
||||
AUTH_OKTA_CLIENT_ID: z.string().optional(),
|
||||
AUTH_OKTA_CLIENT_SECRET: z.string().optional(),
|
||||
AUTH_OKTA_ISSUER: z.string().optional(),
|
||||
AUTH_OKTA_ALLOW_ACCOUNT_LINKING: z.enum(["true", "false"]).optional(),
|
||||
AUTH_OKTA_CHECKS: zAuthChecks,
|
||||
AUTH_OKTA_CLIENT_AUTH_METHOD: zAuthMethod,
|
||||
AUTH_AUTH0_CLIENT_ID: z.string().optional(),
|
||||
AUTH_AUTH0_CLIENT_SECRET: z.string().optional(),
|
||||
AUTH_AUTH0_ISSUER: z.string().url().optional(),
|
||||
AUTH_AUTH0_ALLOW_ACCOUNT_LINKING: z.enum(["true", "false"]).optional(),
|
||||
AUTH_AUTH0_CLIENT_AUTH_METHOD: zAuthMethod,
|
||||
AUTH_AUTH0_CHECKS: zAuthChecks,
|
||||
AUTH_COGNITO_CLIENT_ID: z.string().optional(),
|
||||
AUTH_COGNITO_CLIENT_SECRET: z.string().optional(),
|
||||
AUTH_COGNITO_ISSUER: z.string().url().optional(),
|
||||
AUTH_COGNITO_ALLOW_ACCOUNT_LINKING: z.enum(["true", "false"]).optional(),
|
||||
AUTH_COGNITO_CLIENT_AUTH_METHOD: zAuthMethod,
|
||||
AUTH_COGNITO_CHECKS: zAuthChecks,
|
||||
AUTH_KEYCLOAK_CLIENT_ID: z.string().optional(),
|
||||
AUTH_KEYCLOAK_CLIENT_SECRET: z.string().optional(),
|
||||
AUTH_KEYCLOAK_ISSUER: z.string().optional(),
|
||||
AUTH_KEYCLOAK_ALLOW_ACCOUNT_LINKING: z.enum(["true", "false"]).optional(),
|
||||
AUTH_KEYCLOAK_CLIENT_AUTH_METHOD: zAuthMethod,
|
||||
AUTH_KEYCLOAK_CHECKS: zAuthChecks,
|
||||
AUTH_CUSTOM_CLIENT_ID: z.string().optional(),
|
||||
AUTH_CUSTOM_CLIENT_SECRET: z.string().optional(),
|
||||
AUTH_CUSTOM_ISSUER: z.string().url().optional(),
|
||||
AUTH_CUSTOM_NAME: z.string().optional(),
|
||||
AUTH_CUSTOM_SCOPE: z.string().optional(),
|
||||
AUTH_CUSTOM_CLIENT_AUTH_METHOD: z
|
||||
.enum([
|
||||
"client_secret_basic",
|
||||
"client_secret_post",
|
||||
"client_secret_jwt",
|
||||
"private_key_jwt",
|
||||
"tls_client_auth",
|
||||
"self_signed_tls_client_auth",
|
||||
"none",
|
||||
])
|
||||
.optional(),
|
||||
AUTH_CUSTOM_CLIENT_AUTH_METHOD: zAuthMethod,
|
||||
AUTH_CUSTOM_CHECKS: zAuthChecks,
|
||||
AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING: z.enum(["true", "false"]).optional(),
|
||||
AUTH_CUSTOM_ID_TOKEN: z.enum(["true", "false"]).optional(),
|
||||
AUTH_DOMAINS_WITH_SSO_ENFORCEMENT: z.string().optional(),
|
||||
AUTH_IGNORE_ACCOUNT_FIELDS: z.string().optional(),
|
||||
AUTH_DISABLE_USERNAME_PASSWORD: z.enum(["true", "false"]).optional(),
|
||||
@@ -121,6 +151,8 @@ export const env = createEnv({
|
||||
)
|
||||
.optional()
|
||||
.default(30 * 24 * 60), // default to 30 days
|
||||
AUTH_HTTP_PROXY: z.string().url().optional(),
|
||||
AUTH_HTTPS_PROXY: z.string().url().optional(),
|
||||
// EMAIL
|
||||
EMAIL_FROM_ADDRESS: z.string().optional(),
|
||||
SMTP_CONNECTION_URL: z.string().optional(),
|
||||
@@ -315,10 +347,14 @@ export const env = createEnv({
|
||||
AUTH_GOOGLE_ALLOWED_DOMAINS: process.env.AUTH_GOOGLE_ALLOWED_DOMAINS,
|
||||
AUTH_GOOGLE_ALLOW_ACCOUNT_LINKING:
|
||||
process.env.AUTH_GOOGLE_ALLOW_ACCOUNT_LINKING,
|
||||
AUTH_GOOGLE_CLIENT_AUTH_METHOD: process.env.AUTH_GOOGLE_CLIENT_AUTH_METHOD,
|
||||
AUTH_GOOGLE_CHECKS: process.env.AUTH_GOOGLE_CHECKS,
|
||||
AUTH_GITHUB_CLIENT_ID: process.env.AUTH_GITHUB_CLIENT_ID,
|
||||
AUTH_GITHUB_CLIENT_SECRET: process.env.AUTH_GITHUB_CLIENT_SECRET,
|
||||
AUTH_GITHUB_ALLOW_ACCOUNT_LINKING:
|
||||
process.env.AUTH_GITHUB_ALLOW_ACCOUNT_LINKING,
|
||||
AUTH_GITHUB_CLIENT_AUTH_METHOD: process.env.AUTH_GITHUB_CLIENT_AUTH_METHOD,
|
||||
AUTH_GITHUB_CHECKS: process.env.AUTH_GITHUB_CHECKS,
|
||||
AUTH_GITHUB_ENTERPRISE_CLIENT_ID:
|
||||
process.env.AUTH_GITHUB_ENTERPRISE_CLIENT_ID,
|
||||
AUTH_GITHUB_ENTERPRISE_CLIENT_SECRET:
|
||||
@@ -327,50 +363,69 @@ export const env = createEnv({
|
||||
process.env.AUTH_GITHUB_ENTERPRISE_BASE_URL,
|
||||
AUTH_GITHUB_ENTERPRISE_ALLOW_ACCOUNT_LINKING:
|
||||
process.env.AUTH_GITHUB_ENTERPRISE_ALLOW_ACCOUNT_LINKING,
|
||||
AUTH_GITHUB_ENTERPRISE_CLIENT_AUTH_METHOD:
|
||||
process.env.AUTH_GITHUB_ENTERPRISE_CLIENT_AUTH_METHOD,
|
||||
AUTH_GITHUB_ENTERPRISE_CHECKS: process.env.AUTH_GITHUB_ENTERPRISE_CHECKS,
|
||||
AUTH_GITLAB_ISSUER: process.env.AUTH_GITLAB_ISSUER,
|
||||
AUTH_GITLAB_CLIENT_ID: process.env.AUTH_GITLAB_CLIENT_ID,
|
||||
AUTH_GITLAB_CLIENT_SECRET: process.env.AUTH_GITLAB_CLIENT_SECRET,
|
||||
AUTH_GITLAB_ALLOW_ACCOUNT_LINKING:
|
||||
process.env.AUTH_GITLAB_ALLOW_ACCOUNT_LINKING,
|
||||
AUTH_GITLAB_CLIENT_AUTH_METHOD: process.env.AUTH_GITLAB_CLIENT_AUTH_METHOD,
|
||||
AUTH_GITLAB_CHECKS: process.env.AUTH_GITLAB_CHECKS,
|
||||
AUTH_AZURE_AD_CLIENT_ID: process.env.AUTH_AZURE_AD_CLIENT_ID,
|
||||
AUTH_AZURE_AD_CLIENT_SECRET: process.env.AUTH_AZURE_AD_CLIENT_SECRET,
|
||||
AUTH_AZURE_AD_TENANT_ID: process.env.AUTH_AZURE_AD_TENANT_ID,
|
||||
AUTH_AZURE_ALLOW_ACCOUNT_LINKING:
|
||||
process.env.AUTH_AZURE_ALLOW_ACCOUNT_LINKING,
|
||||
AUTH_AZURE_CLIENT_AUTH_METHOD: process.env.AUTH_AZURE_CLIENT_AUTH_METHOD,
|
||||
AUTH_AZURE_CHECKS: process.env.AUTH_AZURE_CHECKS,
|
||||
AUTH_OKTA_CLIENT_ID: process.env.AUTH_OKTA_CLIENT_ID,
|
||||
AUTH_OKTA_CLIENT_SECRET: process.env.AUTH_OKTA_CLIENT_SECRET,
|
||||
AUTH_OKTA_ISSUER: process.env.AUTH_OKTA_ISSUER,
|
||||
AUTH_OKTA_ALLOW_ACCOUNT_LINKING:
|
||||
process.env.AUTH_OKTA_ALLOW_ACCOUNT_LINKING,
|
||||
AUTH_OKTA_CLIENT_AUTH_METHOD: process.env.AUTH_OKTA_CLIENT_AUTH_METHOD,
|
||||
AUTH_OKTA_CHECKS: process.env.AUTH_OKTA_CHECKS,
|
||||
AUTH_AUTH0_CLIENT_ID: process.env.AUTH_AUTH0_CLIENT_ID,
|
||||
AUTH_AUTH0_CLIENT_SECRET: process.env.AUTH_AUTH0_CLIENT_SECRET,
|
||||
AUTH_AUTH0_ISSUER: process.env.AUTH_AUTH0_ISSUER,
|
||||
AUTH_AUTH0_ALLOW_ACCOUNT_LINKING:
|
||||
process.env.AUTH_AUTH0_ALLOW_ACCOUNT_LINKING,
|
||||
AUTH_AUTH0_CLIENT_AUTH_METHOD: process.env.AUTH_AUTH0_CLIENT_AUTH_METHOD,
|
||||
AUTH_AUTH0_CHECKS: process.env.AUTH_AUTH0_CHECKS,
|
||||
AUTH_COGNITO_CLIENT_ID: process.env.AUTH_COGNITO_CLIENT_ID,
|
||||
AUTH_COGNITO_CLIENT_SECRET: process.env.AUTH_COGNITO_CLIENT_SECRET,
|
||||
AUTH_COGNITO_ISSUER: process.env.AUTH_COGNITO_ISSUER,
|
||||
AUTH_COGNITO_ALLOW_ACCOUNT_LINKING:
|
||||
process.env.AUTH_COGNITO_ALLOW_ACCOUNT_LINKING,
|
||||
AUTH_COGNITO_CLIENT_AUTH_METHOD: process.env.AUTH_COGNITO_CLIENT_AUTH_METHOD,
|
||||
AUTH_COGNITO_CHECKS: process.env.AUTH_COGNITO_CHECKS,
|
||||
AUTH_KEYCLOAK_CLIENT_ID: process.env.AUTH_KEYCLOAK_CLIENT_ID,
|
||||
AUTH_KEYCLOAK_CLIENT_SECRET: process.env.AUTH_KEYCLOAK_CLIENT_SECRET,
|
||||
AUTH_KEYCLOAK_ISSUER: process.env.AUTH_KEYCLOAK_ISSUER,
|
||||
AUTH_KEYCLOAK_ALLOW_ACCOUNT_LINKING:
|
||||
process.env.AUTH_KEYCLOAK_ALLOW_ACCOUNT_LINKING,
|
||||
AUTH_KEYCLOAK_CLIENT_AUTH_METHOD: process.env.AUTH_KEYCLOAK_CLIENT_AUTH_METHOD,
|
||||
AUTH_KEYCLOAK_CHECKS: process.env.AUTH_KEYCLOAK_CHECKS,
|
||||
AUTH_CUSTOM_CLIENT_ID: process.env.AUTH_CUSTOM_CLIENT_ID,
|
||||
AUTH_CUSTOM_CLIENT_SECRET: process.env.AUTH_CUSTOM_CLIENT_SECRET,
|
||||
AUTH_CUSTOM_ISSUER: process.env.AUTH_CUSTOM_ISSUER,
|
||||
AUTH_CUSTOM_NAME: process.env.AUTH_CUSTOM_NAME,
|
||||
AUTH_CUSTOM_SCOPE: process.env.AUTH_CUSTOM_SCOPE,
|
||||
AUTH_CUSTOM_CLIENT_AUTH_METHOD: process.env.AUTH_CUSTOM_CLIENT_AUTH_METHOD,
|
||||
AUTH_CUSTOM_CHECKS: process.env.AUTH_CUSTOM_CHECKS,
|
||||
AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING:
|
||||
process.env.AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING,
|
||||
AUTH_CUSTOM_ID_TOKEN: process.env.AUTH_CUSTOM_ID_TOKEN,
|
||||
AUTH_IGNORE_ACCOUNT_FIELDS: process.env.AUTH_IGNORE_ACCOUNT_FIELDS,
|
||||
AUTH_DOMAINS_WITH_SSO_ENFORCEMENT:
|
||||
process.env.AUTH_DOMAINS_WITH_SSO_ENFORCEMENT,
|
||||
AUTH_DISABLE_USERNAME_PASSWORD: process.env.AUTH_DISABLE_USERNAME_PASSWORD,
|
||||
AUTH_DISABLE_SIGNUP: process.env.AUTH_DISABLE_SIGNUP,
|
||||
AUTH_SESSION_MAX_AGE: process.env.AUTH_SESSION_MAX_AGE,
|
||||
AUTH_HTTP_PROXY: process.env.AUTH_HTTP_PROXY,
|
||||
AUTH_HTTPS_PROXY: process.env.AUTH_HTTPS_PROXY,
|
||||
// Email
|
||||
EMAIL_FROM_ADDRESS: process.env.EMAIL_FROM_ADDRESS,
|
||||
SMTP_CONNECTION_URL: process.env.SMTP_CONNECTION_URL,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePos
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
@@ -123,15 +124,11 @@ const DataRegionInfo = () => (
|
||||
<DialogHeader>
|
||||
<DialogTitle>Data Regions</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-2">
|
||||
<DialogDescription className="flex flex-col gap-2">
|
||||
<p>Langfuse Cloud is available in two data regions:</p>
|
||||
<ul className="list-disc pl-5">
|
||||
<li>
|
||||
US: Oregon (AWS us-west-2)
|
||||
</li>
|
||||
<li>
|
||||
EU: Ireland (AWS eu-west-1)
|
||||
</li>
|
||||
<li>US: Oregon (AWS us-west-2)</li>
|
||||
<li>EU: Ireland (AWS eu-west-1)</li>
|
||||
</ul>
|
||||
<p>
|
||||
Regions are strictly separated, and no data is shared across regions.
|
||||
@@ -154,7 +151,7 @@ const DataRegionInfo = () => (
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import * as z from "zod";
|
||||
|
||||
export const projectRetentionSchema = z.object({
|
||||
retention: z.coerce
|
||||
.number()
|
||||
.int("Must be an integer")
|
||||
.refine((value) => value === 0 || value >= 7, {
|
||||
message: "Value must be 0 or at least 7 days",
|
||||
}),
|
||||
});
|
||||
@@ -14,6 +14,7 @@ const entitlements = [
|
||||
"prompt-experiments",
|
||||
"trace-deletion",
|
||||
"audit-logs",
|
||||
"data-retention",
|
||||
] as const;
|
||||
export type Entitlement = (typeof entitlements)[number];
|
||||
|
||||
@@ -76,6 +77,7 @@ export const entitlementAccess: Record<
|
||||
...cloudAllPlansEntitlements,
|
||||
"rbac-project-roles",
|
||||
"audit-logs",
|
||||
"data-retention",
|
||||
],
|
||||
entitlementLimits: {
|
||||
"annotation-queue-count": false,
|
||||
|
||||
@@ -41,6 +41,12 @@ export const llmApiKeyRouter = createTRPCRouter({
|
||||
data: {
|
||||
projectId: input.projectId,
|
||||
secretKey: encrypt(input.secretKey),
|
||||
extraHeaders: input.extraHeaders
|
||||
? encrypt(JSON.stringify(input.extraHeaders))
|
||||
: undefined,
|
||||
extraHeaderKeys: input.extraHeaders
|
||||
? Object.keys(input.extraHeaders)
|
||||
: undefined,
|
||||
adapter: input.adapter,
|
||||
displaySecretKey: getDisplaySecretKey(input.secretKey),
|
||||
provider: input.provider,
|
||||
@@ -104,10 +110,15 @@ export const llmApiKeyRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
const apiKeys = z
|
||||
.array(LLMApiKeySchema.extend({ secretKey: z.undefined() }))
|
||||
.array(
|
||||
LLMApiKeySchema.extend({
|
||||
secretKey: z.undefined(),
|
||||
extraHeaders: z.undefined(),
|
||||
}),
|
||||
)
|
||||
.parse(
|
||||
await ctx.prisma.llmApiKeys.findMany({
|
||||
// we must not return the secret key via the API, hence not selected
|
||||
// we must not return the secret key AND extra headers via the API, hence not selected
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
@@ -119,6 +130,7 @@ export const llmApiKeyRouter = createTRPCRouter({
|
||||
baseURL: true,
|
||||
customModels: true,
|
||||
withDefaultModels: true,
|
||||
extraHeaderKeys: true,
|
||||
},
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
@@ -169,6 +181,7 @@ export const llmApiKeyRouter = createTRPCRouter({
|
||||
},
|
||||
baseURL: input.baseURL,
|
||||
apiKey: input.secretKey,
|
||||
extraHeaders: input.extraHeaders,
|
||||
messages: testMessages,
|
||||
streaming: false,
|
||||
maxRetries: 1,
|
||||
|
||||
@@ -10,4 +10,5 @@ export const CreateLlmApiKey = z.object({
|
||||
withDefaultModels: z.boolean().optional(),
|
||||
customModels: z.array(z.string().min(1)).optional(),
|
||||
config: BedrockConfigSchema.optional(),
|
||||
extraHeaders: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
@@ -57,6 +57,8 @@ const events = {
|
||||
"apply_labels",
|
||||
"version_delete_open",
|
||||
"version_delete_submit",
|
||||
"duplicate_button_click",
|
||||
"duplicate_form_submit",
|
||||
],
|
||||
session_detail: ["publish_button_click"],
|
||||
eval_config: [
|
||||
@@ -128,6 +130,7 @@ const events = {
|
||||
project_settings: [
|
||||
"project_delete",
|
||||
"rename_form_submit",
|
||||
"retention_form_submit",
|
||||
"project_transfer",
|
||||
"api_key_delete",
|
||||
"api_key_create",
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { Card } from "@/src/components/ui/card";
|
||||
import { Input } from "@/src/components/ui/input";
|
||||
import { api } from "@/src/utils/api";
|
||||
import type * as z from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/src/components/ui/form";
|
||||
import Header from "@/src/components/layouts/header";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { LockIcon } from "lucide-react";
|
||||
import { useQueryProject } from "@/src/features/projects/hooks";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import { projectRetentionSchema } from "@/src/features/auth/lib/projectRetentionSchema";
|
||||
import { ActionButton } from "@/src/components/ActionButton";
|
||||
import { useHasEntitlement } from "@/src/features/entitlements/hooks";
|
||||
|
||||
export default function ConfigureRetention() {
|
||||
const { update: updateSession } = useSession();
|
||||
const { project } = useQueryProject();
|
||||
const capture = usePostHogClientCapture();
|
||||
const hasAccess = useHasProjectAccess({
|
||||
projectId: project?.id,
|
||||
scope: "project:update",
|
||||
});
|
||||
const hasEntitlement = useHasEntitlement("data-retention");
|
||||
|
||||
const form = useForm<z.infer<typeof projectRetentionSchema>>({
|
||||
resolver: zodResolver(projectRetentionSchema),
|
||||
defaultValues: {
|
||||
retention: project?.retentionDays ?? 0,
|
||||
},
|
||||
});
|
||||
const setRetention = api.projects.setRetention.useMutation({
|
||||
onSuccess: (_) => {
|
||||
void updateSession();
|
||||
},
|
||||
onError: (error) => form.setError("retention", { message: error.message }),
|
||||
});
|
||||
|
||||
function onSubmit(values: z.infer<typeof projectRetentionSchema>) {
|
||||
if (!hasAccess || !project) return;
|
||||
capture("project_settings:retention_form_submit");
|
||||
setRetention
|
||||
.mutateAsync({
|
||||
projectId: project.id,
|
||||
retention: values.retention || null, // Fallback to null for indefinite retention
|
||||
})
|
||||
.then(() => {
|
||||
form.reset();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header title="Data Retention (Beta)" level="h3" />
|
||||
<Card className="mb-4 p-3">
|
||||
<p className="mb-4 text-sm text-primary">
|
||||
Data retention automatically deletes events older than the specified
|
||||
number of days. The value must be an integer larger than 7. Set to 0
|
||||
to retain data indefinitely. The deletion happens asynchronously, i.e.
|
||||
event may be available for a while after they expired.
|
||||
</p>
|
||||
{Boolean(form.getValues().retention) &&
|
||||
form.getValues().retention !== project?.retentionDays ? (
|
||||
<p className="mb-4 text-sm text-primary">
|
||||
Your Project's retention will be set from "
|
||||
{project?.retentionDays ?? "Indefinite"}
|
||||
" to "
|
||||
{Number(form.watch().retention) === 0
|
||||
? "Indefinite"
|
||||
: form.watch().retention}
|
||||
" days.
|
||||
</p>
|
||||
) : !Boolean(project?.retentionDays) ? (
|
||||
<p className="mb-4 text-sm text-primary">
|
||||
Your Project retains data indefinitely.
|
||||
</p>
|
||||
) : (
|
||||
<p className="mb-4 text-sm text-primary">
|
||||
Your Project's current retention is "
|
||||
{project?.retentionDays ?? ""}
|
||||
" days.
|
||||
</p>
|
||||
)}
|
||||
<Form {...form}>
|
||||
<form
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex-1"
|
||||
id="set-retention-project-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="retention"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
placeholder={project?.retentionDays?.toString() ?? ""}
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
className="flex-1"
|
||||
disabled={!hasAccess || !hasEntitlement}
|
||||
/>
|
||||
{!hasAccess && (
|
||||
<span title="No access">
|
||||
<LockIcon className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 transform text-muted" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
hasAccess={hasAccess}
|
||||
hasEntitlement={hasEntitlement}
|
||||
loading={setRetention.isLoading}
|
||||
disabled={form.getValues().retention === null}
|
||||
className="mt-4"
|
||||
type="submit"
|
||||
>
|
||||
Save
|
||||
</ActionButton>
|
||||
</form>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -101,6 +101,39 @@ export const projectsRouter = createTRPCRouter({
|
||||
return true;
|
||||
}),
|
||||
|
||||
setRetention: protectedProjectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
retention: z.number().int().gte(7).nullable(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
throwIfNoProjectAccess({
|
||||
session: ctx.session,
|
||||
projectId: input.projectId,
|
||||
scope: "project:update",
|
||||
});
|
||||
|
||||
const project = await ctx.prisma.project.update({
|
||||
where: {
|
||||
id: input.projectId,
|
||||
orgId: ctx.session.orgId,
|
||||
},
|
||||
data: {
|
||||
retentionDays: input.retention,
|
||||
},
|
||||
});
|
||||
await auditLog({
|
||||
session: ctx.session,
|
||||
resourceType: "project",
|
||||
resourceId: input.projectId,
|
||||
action: "update",
|
||||
after: project,
|
||||
});
|
||||
return true;
|
||||
}),
|
||||
|
||||
delete: protectedProjectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/src/components/ui/dialog";
|
||||
import { type Prompt } from "@langfuse/shared";
|
||||
import { type NewPromptFormSchemaType } from "./validation";
|
||||
import DiffViewer from "@/src/components/DiffViewer";
|
||||
|
||||
type ReviewPromptDialogProps = {
|
||||
initialPrompt: Prompt;
|
||||
isLoading: boolean;
|
||||
children: React.ReactNode;
|
||||
onConfirm: () => void;
|
||||
getNewPromptValues: () => NewPromptFormSchemaType;
|
||||
};
|
||||
|
||||
export const ReviewPromptDialog: React.FC<ReviewPromptDialogProps> = (
|
||||
props,
|
||||
) => {
|
||||
const { initialPrompt, children, getNewPromptValues, onConfirm, isLoading } =
|
||||
props;
|
||||
const [newPromptValue, setNewPromptValues] =
|
||||
React.useState<NewPromptFormSchemaType | null>(null);
|
||||
const [open, setOpen] = React.useState<boolean>(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setNewPromptValues(getNewPromptValues());
|
||||
}
|
||||
}, [open, setNewPromptValues, getNewPromptValues]);
|
||||
|
||||
const initialPromptContent: string =
|
||||
initialPrompt.type === "text"
|
||||
? (initialPrompt.prompt as string)
|
||||
: JSON.stringify(initialPrompt.prompt, null, 2);
|
||||
|
||||
const newPromptContent: string =
|
||||
initialPrompt.type === "text"
|
||||
? (newPromptValue?.textPrompt ?? "")
|
||||
: JSON.stringify(
|
||||
newPromptValue?.chatPrompt.map((m) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(m).filter(([k, _]) => k !== "id"),
|
||||
),
|
||||
) ?? "{}",
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
const newConfig = JSON.stringify(
|
||||
JSON.parse(newPromptValue?.config ?? "{}"),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(open) => setOpen(open)}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className="max-w-screen-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Review Prompt Changes</DialogTitle>
|
||||
<DialogDescription className="flex items-center gap-2">
|
||||
<span className="font-medium">{initialPrompt.name}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[80vh] max-w-screen-xl space-y-6 overflow-y-auto">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="mb-2 text-base font-medium">Content</h3>
|
||||
<DiffViewer
|
||||
oldString={initialPromptContent}
|
||||
newString={newPromptContent}
|
||||
oldLabel={`Previous content (v${initialPrompt.version})`}
|
||||
newLabel="New content (draft)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-2 text-base font-medium">Config</h3>
|
||||
<DiffViewer
|
||||
oldString={JSON.stringify(initialPrompt.config, null, 2)}
|
||||
newString={newConfig ?? "failed"}
|
||||
oldLabel={`Previous config (v${initialPrompt.version})`}
|
||||
newLabel="New config (draft)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setOpen(false)}
|
||||
className="min-w-[8rem]"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
loading={isLoading}
|
||||
variant={newPromptValue?.isActive ? "destructive" : "default"}
|
||||
className="min-w-[8rem]"
|
||||
>
|
||||
Save new version
|
||||
{newPromptValue?.isActive ? " and promote to production" : ""}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
getIsCharOrUnderscore,
|
||||
} from "@langfuse/shared";
|
||||
import { PromptChatMessages } from "./PromptChatMessages";
|
||||
import { ReviewPromptDialog } from "./ReviewPromptDialog";
|
||||
import {
|
||||
NewPromptFormSchema,
|
||||
type NewPromptFormSchemaType,
|
||||
@@ -49,6 +50,7 @@ import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePos
|
||||
import usePlaygroundCache from "@/src/ee/features/playground/page/hooks/usePlaygroundCache";
|
||||
import { useQueryParam } from "use-query-params";
|
||||
import { Switch } from "@/src/components/ui/switch";
|
||||
import { usePromptNameValidation } from "@/src/features/prompts/hooks/usePromptNameValidation";
|
||||
|
||||
type NewPromptFormProps = {
|
||||
initialPrompt?: Prompt | null;
|
||||
@@ -94,6 +96,7 @@ export const NewPromptForm: React.FC<NewPromptFormProps> = (props) => {
|
||||
|
||||
const form = useForm<NewPromptFormSchemaType>({
|
||||
resolver: zodResolver(NewPromptFormSchema),
|
||||
mode: "onTouched",
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
@@ -184,24 +187,11 @@ export const NewPromptForm: React.FC<NewPromptFormProps> = (props) => {
|
||||
}
|
||||
}, [playgroundCache, initialPrompt, form, shouldLoadPlaygroundCache]);
|
||||
|
||||
useEffect(() => {
|
||||
const isNewPrompt = !allPrompts
|
||||
?.map((prompt) => prompt.value)
|
||||
.includes(currentName);
|
||||
|
||||
if (!isNewPrompt) {
|
||||
form.setError("name", { message: "Prompt name already exist." });
|
||||
} else if (currentName === "new") {
|
||||
form.setError("name", { message: "Prompt name cannot be 'new'" });
|
||||
} else if (currentName && !/^[a-zA-Z0-9_\-.]+$/.test(currentName)) {
|
||||
form.setError("name", {
|
||||
message:
|
||||
"Name must be alphanumeric with optional underscores, hyphens, or periods",
|
||||
});
|
||||
} else {
|
||||
form.clearErrors("name");
|
||||
}
|
||||
}, [currentName, allPrompts, form]);
|
||||
usePromptNameValidation({
|
||||
currentName,
|
||||
allPrompts,
|
||||
form,
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
@@ -356,6 +346,7 @@ export const NewPromptForm: React.FC<NewPromptFormProps> = (props) => {
|
||||
<JsonEditor
|
||||
defaultValue={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
editable
|
||||
/>
|
||||
<FormDescription>
|
||||
@@ -392,16 +383,44 @@ export const NewPromptForm: React.FC<NewPromptFormProps> = (props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={createPromptMutation.isLoading}
|
||||
className="w-full"
|
||||
disabled={Boolean(
|
||||
!initialPrompt && form.formState.errors.name?.message,
|
||||
)} // Disable button if prompt name already exists. Check is dynamic and not part of zod schema
|
||||
>
|
||||
{!initialPrompt ? "Create prompt" : "Save prompt version"}
|
||||
</Button>
|
||||
{initialPrompt ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<ReviewPromptDialog
|
||||
initialPrompt={initialPrompt}
|
||||
getNewPromptValues={form.getValues}
|
||||
isLoading={createPromptMutation.isLoading}
|
||||
onConfirm={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<Button
|
||||
disabled={!form.formState.isValid}
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
>
|
||||
Review changes
|
||||
</Button>
|
||||
</ReviewPromptDialog>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
loading={createPromptMutation.isLoading}
|
||||
className="w-full"
|
||||
disabled={!form.formState.isValid}
|
||||
>
|
||||
Save new prompt version
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
loading={createPromptMutation.isLoading}
|
||||
className="w-full"
|
||||
disabled={Boolean(
|
||||
!initialPrompt && form.formState.errors.name?.message,
|
||||
)} // Disable button if prompt name already exists. Check is dynamic and not part of zod schema
|
||||
>
|
||||
Create prompt
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
{formError && (
|
||||
<p className="text-red text-center">
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/src/components/ui/dialog";
|
||||
import { type Prompt } from "@langfuse/shared";
|
||||
import DiffViewer from "@/src/components/DiffViewer";
|
||||
import { FileDiffIcon } from "lucide-react";
|
||||
|
||||
type PromptVersionDiffDialogProps = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
leftPrompt: Prompt;
|
||||
rightPrompt: Prompt;
|
||||
};
|
||||
|
||||
export const PromptVersionDiffDialog: React.FC<PromptVersionDiffDialogProps> = (
|
||||
props,
|
||||
) => {
|
||||
const { leftPrompt, rightPrompt, isOpen, setIsOpen } = props;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
size="icon"
|
||||
className="h-7 w-7 px-0"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
title="Compare with selected prompt"
|
||||
>
|
||||
<FileDiffIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent
|
||||
className="max-w-screen-xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Changes v{leftPrompt.version} → v{rightPrompt.version}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="flex items-center gap-2">
|
||||
<span className="font-medium">Prompt {leftPrompt.name}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[80vh] max-w-screen-xl space-y-6 overflow-y-auto">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="mb-2 text-base font-medium">Content</h3>
|
||||
<DiffViewer
|
||||
oldString={
|
||||
leftPrompt.type === "chat"
|
||||
? JSON.stringify(leftPrompt.prompt, null, 2)
|
||||
: (leftPrompt.prompt as string)
|
||||
}
|
||||
newString={
|
||||
rightPrompt.type === "chat"
|
||||
? JSON.stringify(rightPrompt.prompt, null, 2)
|
||||
: (rightPrompt.prompt as string)
|
||||
}
|
||||
oldLabel={`v${leftPrompt.version}`}
|
||||
newLabel={`v${rightPrompt.version}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-2 text-base font-medium">Config</h3>
|
||||
<DiffViewer
|
||||
oldString={JSON.stringify(leftPrompt.config, null, 2)}
|
||||
newString={JSON.stringify(rightPrompt.config, null, 2)}
|
||||
oldLabel={`v${leftPrompt.version}`}
|
||||
newLabel={`v${rightPrompt.version}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex flex-row">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { Copy } from "lucide-react";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/src/components/ui/dialog";
|
||||
import { ActionButton } from "@/src/components/ActionButton";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { useEntitlementLimit } from "@/src/features/entitlements/hooks";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/src/components/ui/form";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Input } from "@/src/components/ui/input";
|
||||
import { RadioGroup, RadioGroupItem } from "@/src/components/ui/radio-group";
|
||||
import { usePromptNameValidation } from "@/src/features/prompts/hooks/usePromptNameValidation";
|
||||
|
||||
enum CopySettings {
|
||||
SINGLE_VERSION = "single_version",
|
||||
ALL_VERSIONS = "all_versions",
|
||||
}
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
isCopySingleVersion: z.nativeEnum(CopySettings),
|
||||
});
|
||||
|
||||
const DuplicatePromptForm: React.FC<{
|
||||
projectId: string;
|
||||
promptId: string;
|
||||
promptName: string;
|
||||
promptVersion: number;
|
||||
onFormSuccess: () => void;
|
||||
}> = ({ projectId, promptId, promptName, promptVersion, onFormSuccess }) => {
|
||||
const capture = usePostHogClientCapture();
|
||||
const router = useRouter();
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: promptName,
|
||||
isCopySingleVersion: CopySettings.SINGLE_VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
const currentName = form.watch("name");
|
||||
|
||||
const utils = api.useUtils();
|
||||
const duplicatePrompt = api.prompts.duplicatePrompt.useMutation({
|
||||
onSuccess: ({ name }) => {
|
||||
utils.prompts.invalidate();
|
||||
void router.push(
|
||||
`/project/${projectId}/prompts/${encodeURIComponent(name)}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
capture("prompt_detail:duplicate_form_submit");
|
||||
duplicatePrompt
|
||||
.mutateAsync({
|
||||
...values,
|
||||
projectId: projectId,
|
||||
promptId: promptId,
|
||||
isSingleVersion:
|
||||
values.isCopySingleVersion === CopySettings.SINGLE_VERSION,
|
||||
})
|
||||
.then(() => {
|
||||
onFormSuccess();
|
||||
form.reset();
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
|
||||
const allPrompts = api.prompts.filterOptions.useQuery(
|
||||
{
|
||||
projectId: projectId,
|
||||
},
|
||||
{
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: Infinity,
|
||||
},
|
||||
).data?.name;
|
||||
|
||||
usePromptNameValidation({
|
||||
currentName,
|
||||
allPrompts,
|
||||
form,
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="mt-4 flex h-full flex-1 flex-col gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col gap-2">
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="text" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isCopySingleVersion"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Settings</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
{...field}
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
className="flex flex-col space-y-1"
|
||||
>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value={CopySettings.SINGLE_VERSION} />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">
|
||||
Copy only version {promptVersion}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value={CopySettings.ALL_VERSIONS} />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">
|
||||
Copy all prompt versions and labels
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
loading={duplicatePrompt.isLoading}
|
||||
className="mb-4 mt-auto w-full"
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export const DuplicatePromptButton: React.FC<{
|
||||
projectId: string;
|
||||
promptId: string;
|
||||
promptName: string;
|
||||
promptVersion: number;
|
||||
}> = ({ projectId, promptId, promptName, promptVersion }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const hasAccess = useHasProjectAccess({
|
||||
projectId,
|
||||
scope: "prompts:CUD",
|
||||
});
|
||||
const promptLimit = useEntitlementLimit("prompt-management-count-prompts");
|
||||
const capture = usePostHogClientCapture();
|
||||
|
||||
const allPromptNames = api.prompts.allNames.useQuery(
|
||||
{
|
||||
projectId,
|
||||
},
|
||||
{
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
enabled: hasAccess,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={hasAccess && open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<ActionButton
|
||||
icon={<Copy className="mr-1 h-4 w-4" aria-hidden="true" />}
|
||||
hasAccess={hasAccess}
|
||||
variant="secondary"
|
||||
limit={promptLimit}
|
||||
title="Duplicate prompt"
|
||||
limitValue={allPromptNames.data?.length ?? undefined}
|
||||
onClick={() => {
|
||||
capture("prompt_detail:duplicate_button_click");
|
||||
}}
|
||||
>
|
||||
Duplicate
|
||||
</ActionButton>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[90vh] min-h-0">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Duplicate prompt</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DuplicatePromptForm
|
||||
projectId={projectId}
|
||||
promptId={promptId}
|
||||
promptName={promptName}
|
||||
promptVersion={promptVersion}
|
||||
onFormSuccess={() => setOpen(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -39,6 +39,7 @@ import { CreateExperimentsForm } from "@/src/ee/features/experiments/components/
|
||||
import { useState } from "react";
|
||||
import { useHasEntitlement } from "@/src/features/entitlements/hooks";
|
||||
import { showSuccessToast } from "@/src/features/notifications/showSuccessToast";
|
||||
import { DuplicatePromptButton } from "@/src/features/prompts/components/duplicate-prompt";
|
||||
|
||||
export const PromptDetail = () => {
|
||||
const projectId = useProjectIdFromURL();
|
||||
@@ -224,6 +225,14 @@ export const PromptDetail = () => {
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
{projectId && (
|
||||
<DuplicatePromptButton
|
||||
promptId={prompt.id}
|
||||
projectId={projectId}
|
||||
promptName={prompt.name}
|
||||
promptVersion={prompt.version}
|
||||
/>
|
||||
)}
|
||||
<DetailPageNav
|
||||
key="nav"
|
||||
currentId={promptName}
|
||||
|
||||
@@ -5,10 +5,12 @@ import { PRODUCTION_LABEL } from "@/src/features/prompts/constants";
|
||||
import { type RouterOutputs } from "@/src/utils/api";
|
||||
import { type NextRouter, useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { PromptVersionDiffDialog } from "./PromptVersionDiffDialog";
|
||||
|
||||
const PromptHistoryTraceNode = (props: {
|
||||
index: number;
|
||||
prompt: RouterOutputs["prompts"]["allVersions"]["promptVersions"][number];
|
||||
currentPrompt?: RouterOutputs["prompts"]["allVersions"]["promptVersions"][number];
|
||||
currentPromptVersion: number | undefined;
|
||||
setCurrentPromptVersion: (version: number | undefined) => void;
|
||||
router: NextRouter;
|
||||
@@ -17,6 +19,7 @@ const PromptHistoryTraceNode = (props: {
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isLabelPopoverOpen, setIsLabelPopoverOpen] = useState(false);
|
||||
const [isPromptDiffOpen, setIsPromptDiffOpen] = useState(false);
|
||||
const { prompt } = props;
|
||||
let badges: JSX.Element[] = prompt.labels
|
||||
.sort((a, b) =>
|
||||
@@ -70,8 +73,22 @@ const PromptHistoryTraceNode = (props: {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{(isHovered || props.currentPromptVersion === prompt.version) && (
|
||||
{(isHovered ||
|
||||
props.currentPromptVersion === prompt.version ||
|
||||
isPromptDiffOpen) && (
|
||||
<div className="flex flex-row justify-end space-x-1">
|
||||
{props.currentPrompt &&
|
||||
props.currentPromptVersion !== prompt.version ? (
|
||||
<PromptVersionDiffDialog
|
||||
isOpen={isPromptDiffOpen}
|
||||
setIsOpen={(open) => {
|
||||
setIsPromptDiffOpen(open);
|
||||
if (!open) setIsHovered(false);
|
||||
}}
|
||||
leftPrompt={prompt}
|
||||
rightPrompt={props.currentPrompt}
|
||||
/>
|
||||
) : null}
|
||||
<SetPromptVersionLabels
|
||||
prompt={prompt}
|
||||
isOpen={isLabelPopoverOpen}
|
||||
@@ -100,6 +117,10 @@ export const PromptHistoryNode = (props: {
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const projectId = router.query.projectId as string;
|
||||
const currentPrompt = props.prompts.find(
|
||||
(p) => p.version === props.currentPromptVersion,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full flex-1">
|
||||
{props.prompts.map((prompt, index) => (
|
||||
@@ -107,6 +128,7 @@ export const PromptHistoryNode = (props: {
|
||||
key={prompt.id}
|
||||
index={index}
|
||||
prompt={prompt}
|
||||
currentPrompt={currentPrompt}
|
||||
currentPromptVersion={props.currentPromptVersion}
|
||||
setCurrentPromptVersion={props.setCurrentPromptVersion}
|
||||
router={router}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useEffect } from "react";
|
||||
import { type UseFormReturn } from "react-hook-form";
|
||||
|
||||
interface UsePromptNameValidationProps {
|
||||
currentName: string | undefined;
|
||||
allPrompts: { value: string }[] | undefined;
|
||||
form: UseFormReturn<any>;
|
||||
}
|
||||
|
||||
export const usePromptNameValidation = ({
|
||||
currentName,
|
||||
allPrompts,
|
||||
form,
|
||||
}: UsePromptNameValidationProps) => {
|
||||
useEffect(() => {
|
||||
if (!currentName) return;
|
||||
|
||||
const isNewPrompt = !allPrompts
|
||||
?.map((prompt) => prompt.value)
|
||||
.includes(currentName);
|
||||
|
||||
if (!isNewPrompt) {
|
||||
form.setError("name", { message: "Prompt name already exist." });
|
||||
} else if (currentName === "new") {
|
||||
form.setError("name", { message: "Prompt name cannot be 'new'" });
|
||||
} else if (!/^[a-zA-Z0-9_\-.]+$/.test(currentName)) {
|
||||
form.setError("name", {
|
||||
message:
|
||||
"Name must be alphanumeric with optional underscores, hyphens, or periods",
|
||||
});
|
||||
} else {
|
||||
form.clearErrors("name");
|
||||
}
|
||||
}, [currentName, allPrompts, form]);
|
||||
};
|
||||
@@ -8,13 +8,26 @@ 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 { PromptService, redis } from "@langfuse/shared/src/server";
|
||||
import {
|
||||
PromptContentSchema,
|
||||
PromptService,
|
||||
redis,
|
||||
} from "@langfuse/shared/src/server";
|
||||
|
||||
export type CreatePromptParams = CreatePromptTRPCType & {
|
||||
createdBy: string;
|
||||
prisma: PrismaClient;
|
||||
};
|
||||
|
||||
type DuplicatePromptParams = {
|
||||
projectId: string;
|
||||
promptId: string;
|
||||
name: string;
|
||||
isSingleVersion: boolean;
|
||||
createdBy: string;
|
||||
prisma: PrismaClient;
|
||||
};
|
||||
|
||||
export const createPrompt = async ({
|
||||
projectId,
|
||||
name,
|
||||
@@ -96,3 +109,78 @@ export const createPrompt = async ({
|
||||
|
||||
return createdPrompt;
|
||||
};
|
||||
|
||||
export const duplicatePrompt = async ({
|
||||
projectId,
|
||||
promptId,
|
||||
name,
|
||||
isSingleVersion,
|
||||
createdBy,
|
||||
prisma,
|
||||
}: DuplicatePromptParams) => {
|
||||
// validate that name is unique in project, uniqueness constraint too permissive as it includes version
|
||||
const promptNameExists = await prisma.prompt.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
name,
|
||||
},
|
||||
});
|
||||
|
||||
if (promptNameExists) {
|
||||
throw new InvalidRequestError(
|
||||
`Prompt name ${name} already exists in project ${projectId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const existingPrompt = await prisma.prompt.findUnique({
|
||||
where: {
|
||||
id: promptId,
|
||||
projectId: projectId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingPrompt) {
|
||||
throw new Error(`Existing prompt not found: ${promptId}`);
|
||||
}
|
||||
|
||||
// if defined as single version, duplicate current prompt as new prompt v1
|
||||
// else duplicate the entire prompt, should be all or nothing operation.
|
||||
const promptsDb = await prisma.prompt.findMany({
|
||||
where: {
|
||||
projectId: projectId,
|
||||
name: existingPrompt.name,
|
||||
version: isSingleVersion ? existingPrompt.version : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// prepare createMany prompt records
|
||||
const promptsToCreate = promptsDb.map((prompt) => ({
|
||||
name,
|
||||
version: isSingleVersion ? 1 : prompt.version,
|
||||
labels: isSingleVersion
|
||||
? [...new Set([LATEST_PROMPT_LABEL, ...prompt.labels])]
|
||||
: prompt.labels,
|
||||
type: prompt.type,
|
||||
prompt: PromptContentSchema.parse(prompt.prompt),
|
||||
config: jsonSchema.parse(prompt.config),
|
||||
tags: prompt.tags,
|
||||
projectId,
|
||||
createdBy,
|
||||
}));
|
||||
|
||||
// Create all prompts in a single operation
|
||||
const result = await prisma.prompt.createMany({
|
||||
data: promptsToCreate,
|
||||
});
|
||||
|
||||
// If you need the created prompt, fetch it separately since createMany doesn't return created records
|
||||
const createdPrompt = await prisma.prompt.findFirst({
|
||||
where: {
|
||||
name,
|
||||
projectId,
|
||||
version: isSingleVersion ? 1 : result.count,
|
||||
},
|
||||
});
|
||||
|
||||
return createdPrompt;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { logger, PromptService } from "@langfuse/shared/src/server";
|
||||
import { removeLabelsFromPreviousPromptVersions } from "@/src/features/prompts/server/utils/updatePromptLabels";
|
||||
import { LangfuseNotFoundError } from "@langfuse/shared";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { redis } from "@langfuse/shared/src/server";
|
||||
|
||||
export type UpdatePromptParams = {
|
||||
promptName: string;
|
||||
projectId: string;
|
||||
promptVersion: number;
|
||||
newLabels: string[];
|
||||
};
|
||||
|
||||
export const updatePrompt = async (params: UpdatePromptParams) => {
|
||||
const { promptName, projectId, promptVersion, newLabels } = params;
|
||||
|
||||
logger.info(
|
||||
`Updating prompt ${promptName} in project ${projectId} version ${promptVersion} with labels ${newLabels}`,
|
||||
);
|
||||
const promptService = new PromptService(prisma, redis);
|
||||
try {
|
||||
await promptService.lockCache({ projectId, promptName: promptName });
|
||||
|
||||
const prompt = await promptService.getPrompt({
|
||||
projectId,
|
||||
promptName,
|
||||
version: promptVersion,
|
||||
label: undefined,
|
||||
});
|
||||
|
||||
if (!prompt) {
|
||||
throw new LangfuseNotFoundError(`Prompt not found: ${promptName}`);
|
||||
}
|
||||
|
||||
const newLabelsSet = new Set([...newLabels, ...prompt.labels]);
|
||||
|
||||
logger.info(
|
||||
`Setting labels for prompt: ${prompt.id}, ${prompt.name}, ${prompt.version}, ${JSON.stringify(
|
||||
Array.from(newLabelsSet),
|
||||
)}`,
|
||||
);
|
||||
|
||||
const tx = [
|
||||
...(await removeLabelsFromPreviousPromptVersions({
|
||||
prisma,
|
||||
projectId,
|
||||
promptName,
|
||||
labelsToRemove: [...new Set(newLabels)],
|
||||
})),
|
||||
prisma.prompt.update({
|
||||
where: {
|
||||
id: prompt.id,
|
||||
projectId,
|
||||
},
|
||||
data: {
|
||||
labels: {
|
||||
set: Array.from(newLabelsSet),
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
await promptService.invalidateCache({ projectId, promptName: promptName });
|
||||
|
||||
const res = await prisma.$transaction(tx);
|
||||
|
||||
await promptService.unlockCache({ projectId, promptName: promptName });
|
||||
|
||||
return res[res.length - 1];
|
||||
} catch (e) {
|
||||
await promptService.unlockCache({ projectId, promptName: promptName });
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
@@ -48,4 +48,6 @@ const getPromptNameHandler = async (
|
||||
return res.status(200).json(prompt);
|
||||
};
|
||||
|
||||
export const promptNameHandler = withMiddlewares({ GET: getPromptNameHandler });
|
||||
export const promptNameHandler = withMiddlewares({
|
||||
GET: getPromptNameHandler,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { logger } from "@langfuse/shared/src/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares";
|
||||
import { createAuthedAPIRoute } from "@/src/features/public-api/server/createAuthedAPIRoute";
|
||||
import { updatePrompt } from "@/src/features/prompts/server/actions/updatePrompts";
|
||||
import { LangfuseNotFoundError } from "@langfuse/shared";
|
||||
|
||||
const UpdatePromptBodySchema = z.object({
|
||||
newLabels: z
|
||||
.array(z.string())
|
||||
.refine((labels) => !labels.includes("latest"), {
|
||||
message: "Label 'latest' is always assigned to the latest prompt version",
|
||||
}),
|
||||
});
|
||||
|
||||
export const promptVersionHandler = withMiddlewares({
|
||||
PATCH: createAuthedAPIRoute({
|
||||
name: "Update Prompt",
|
||||
bodySchema: UpdatePromptBodySchema,
|
||||
responseSchema: z.any(),
|
||||
fn: async ({ body, res, req, auth }) => {
|
||||
try {
|
||||
const { newLabels } = UpdatePromptBodySchema.parse(body);
|
||||
const { promptName, promptVersion } = req.query;
|
||||
|
||||
const prompt = await updatePrompt({
|
||||
promptName: promptName as string,
|
||||
projectId: auth.scope.projectId,
|
||||
promptVersion: Number(promptVersion),
|
||||
newLabels,
|
||||
});
|
||||
|
||||
logger.info(`Prompt updated ${JSON.stringify(prompt)}`);
|
||||
|
||||
return res.status(200).json(prompt);
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
if (e instanceof LangfuseNotFoundError) {
|
||||
return res.status(404).json({ message: e.message });
|
||||
}
|
||||
return res.status(500).json({ message: "Internal server error" });
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
protectedProjectProcedure,
|
||||
} from "@/src/server/api/trpc";
|
||||
import { type Prompt, Prisma } from "@langfuse/shared/src/db";
|
||||
import { createPrompt } from "../actions/createPrompt";
|
||||
import { createPrompt, duplicatePrompt } from "../actions/createPrompt";
|
||||
import { promptsTableCols } from "@/src/server/api/definitions/promptsTable";
|
||||
import { optionalPaginationZod, paginationZod } from "@langfuse/shared";
|
||||
import { orderBy, singleFilter } from "@langfuse/shared";
|
||||
@@ -172,6 +172,48 @@ export const promptRouter = createTRPCRouter({
|
||||
throw e;
|
||||
}
|
||||
}),
|
||||
duplicatePrompt: protectedProjectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
promptId: z.string(),
|
||||
name: z.string(),
|
||||
isSingleVersion: z.boolean(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
throwIfNoProjectAccess({
|
||||
session: ctx.session,
|
||||
projectId: input.projectId,
|
||||
scope: "prompts:CUD",
|
||||
});
|
||||
|
||||
const prompt = await duplicatePrompt({
|
||||
projectId: input.projectId,
|
||||
promptId: input.promptId,
|
||||
name: input.name,
|
||||
isSingleVersion: input.isSingleVersion,
|
||||
createdBy: ctx.session.user.id,
|
||||
prisma: ctx.prisma,
|
||||
});
|
||||
|
||||
if (!prompt) {
|
||||
throw new Error(`Failed to duplicate prompt: ${input.promptId}`);
|
||||
}
|
||||
|
||||
await auditLog(
|
||||
{
|
||||
session: ctx.session,
|
||||
resourceType: "prompt",
|
||||
resourceId: prompt.id,
|
||||
action: "create",
|
||||
after: prompt,
|
||||
},
|
||||
ctx.prisma,
|
||||
);
|
||||
|
||||
return prompt;
|
||||
}),
|
||||
filterOptions: protectedProjectProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(async ({ input, ctx }) => {
|
||||
@@ -454,7 +496,7 @@ export const promptRouter = createTRPCRouter({
|
||||
// Unlock cache
|
||||
await promptService.unlockCache({ projectId, promptName });
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
logger.error(`Failed to set prompt labels: ${e}`, e);
|
||||
throw e;
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -44,6 +44,9 @@ const formSchema = z
|
||||
awsAccessKeyId: z.string().optional(),
|
||||
awsSecretAccessKey: z.string().optional(),
|
||||
awsRegion: z.string().optional(),
|
||||
extraHeaders: z.array(
|
||||
z.object({ key: z.string().min(1), value: z.string().min(1) }),
|
||||
),
|
||||
})
|
||||
.refine((data) => data.withDefaultModels || data.customModels.length > 0, {
|
||||
message:
|
||||
@@ -115,6 +118,7 @@ export function CreateLLMApiKeyForm({
|
||||
baseURL: getCustomizedBaseURL(defaultAdapter),
|
||||
withDefaultModels: true,
|
||||
customModels: [],
|
||||
extraHeaders: [],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -125,6 +129,15 @@ export function CreateLLMApiKeyForm({
|
||||
name: "customModels",
|
||||
});
|
||||
|
||||
const {
|
||||
fields: headerFields,
|
||||
append: appendHeader,
|
||||
remove: removeHeader,
|
||||
} = useFieldArray({
|
||||
control: form.control,
|
||||
name: "extraHeaders",
|
||||
});
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
if (!projectId) return console.error("No project ID found.");
|
||||
if (
|
||||
@@ -155,6 +168,17 @@ export function CreateLLMApiKeyForm({
|
||||
};
|
||||
}
|
||||
|
||||
const extraHeaders =
|
||||
values.extraHeaders.length > 0
|
||||
? values.extraHeaders.reduce(
|
||||
(acc, header) => {
|
||||
acc[header.key] = header.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const newKey = {
|
||||
projectId,
|
||||
secretKey: secretKey ?? "",
|
||||
@@ -166,6 +190,7 @@ export function CreateLLMApiKeyForm({
|
||||
customModels: values.customModels
|
||||
.map((m) => m.value.trim())
|
||||
.filter(Boolean),
|
||||
extraHeaders,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -383,6 +408,57 @@ export function CreateLLMApiKeyForm({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Extra Headers */}
|
||||
{currentAdapter === "openai" ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="extraHeaders"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>Extra Headers</FormLabel>
|
||||
<FormDescription>
|
||||
Optional additional HTTP headers to include with requests
|
||||
towards LLM provider. All header values stored encrypted on
|
||||
our servers.
|
||||
</FormDescription>
|
||||
|
||||
{headerFields.map((header, index) => (
|
||||
<div key={header.id} className="flex flex-row space-x-2">
|
||||
<Input
|
||||
{...form.register(`extraHeaders.${index}.key`)}
|
||||
placeholder="Header name"
|
||||
/>
|
||||
<Input
|
||||
{...form.register(`extraHeaders.${index}.value`)}
|
||||
placeholder="Header value"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => removeHeader(index)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => appendHeader({ key: "", value: "" })}
|
||||
className="w-full"
|
||||
>
|
||||
<PlusIcon
|
||||
className="-ml-0.5 mr-1.5 h-5 w-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Add Header
|
||||
</Button>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* With default models */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -49,6 +49,10 @@ export function LlmApiKeyList(props: { projectId: string }) {
|
||||
},
|
||||
);
|
||||
|
||||
const hasExtraHeaderKeys = apiKeys.data?.data.some(
|
||||
(key) => key.extraHeaderKeys.length > 0,
|
||||
);
|
||||
|
||||
if (!isAvailable) return null;
|
||||
|
||||
if (!hasAccess) {
|
||||
@@ -89,6 +93,9 @@ export function LlmApiKeyList(props: { projectId: string }) {
|
||||
Base URL
|
||||
</TableHead>
|
||||
<TableHead className="text-primary">Secret Key</TableHead>
|
||||
{hasExtraHeaderKeys ? (
|
||||
<TableHead className="text-primary">Extra headers</TableHead>
|
||||
) : null}
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -116,6 +123,9 @@ export function LlmApiKeyList(props: { projectId: string }) {
|
||||
<TableCell className="font-mono">
|
||||
{apiKey.displaySecretKey}
|
||||
</TableCell>
|
||||
{hasExtraHeaderKeys ? (
|
||||
<TableCell> {apiKey.extraHeaderKeys.join(", ")} </TableCell>
|
||||
) : null}
|
||||
<TableCell>
|
||||
<DeleteApiKeyButton
|
||||
projectId={props.projectId}
|
||||
|
||||
@@ -5,15 +5,11 @@ import {
|
||||
TRACE_TO_OBSERVATIONS_INTERVAL,
|
||||
orderByToClickhouseSql,
|
||||
type DateTimeFilter,
|
||||
parseClickhouseUTCDateTimeFormat,
|
||||
convertClickhouseToDomain,
|
||||
type TraceRecordReadType,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import {
|
||||
convertRecordToJsonSchema,
|
||||
type OrderByState,
|
||||
type Trace,
|
||||
} from "@langfuse/shared";
|
||||
import { type OrderByState } from "@langfuse/shared";
|
||||
import { snakeCase } from "lodash";
|
||||
import { type JsonValue } from "@prisma/client/runtime/binary";
|
||||
|
||||
type QueryType = {
|
||||
page: number;
|
||||
@@ -82,22 +78,21 @@ export const generateTracesForPublicApi = async (
|
||||
SELECT
|
||||
t.id as id,
|
||||
CONCAT('/project/', t.project_id, '/traces/', t.id) as "htmlPath",
|
||||
t.project_id as projectId,
|
||||
t.project_id as project_id,
|
||||
t.timestamp as timestamp,
|
||||
t.name as name,
|
||||
t.input as input,
|
||||
t.output as output,
|
||||
null as externalId,
|
||||
t.session_id as sessionId,
|
||||
t.session_id as session_id,
|
||||
t.metadata as metadata,
|
||||
t.user_id as userId,
|
||||
t.user_id as user_id,
|
||||
t.release as release,
|
||||
t.version as version,
|
||||
t.bookmarked as bookmarked,
|
||||
t.public as public,
|
||||
t.tags as tags,
|
||||
t.created_at as createdAt,
|
||||
t.updated_at as updatedAt,
|
||||
t.created_at as created_at,
|
||||
t.updated_at as updated_at,
|
||||
s.score_ids as scores,
|
||||
o.observation_ids as observations,
|
||||
COALESCE(o.latency_milliseconds / 1000, 0) as latency,
|
||||
@@ -113,7 +108,7 @@ export const generateTracesForPublicApi = async (
|
||||
`;
|
||||
|
||||
const result = await queryClickhouse<
|
||||
Trace & {
|
||||
TraceRecordReadType & {
|
||||
observations: string[];
|
||||
scores: string[];
|
||||
totalCost: number;
|
||||
@@ -138,14 +133,12 @@ export const generateTracesForPublicApi = async (
|
||||
});
|
||||
|
||||
return result.map((trace) => ({
|
||||
...trace,
|
||||
timestamp: parseClickhouseUTCDateTimeFormat(trace.timestamp.toString()),
|
||||
createdAt: parseClickhouseUTCDateTimeFormat(trace.createdAt.toString()),
|
||||
updatedAt: parseClickhouseUTCDateTimeFormat(trace.updatedAt.toString()),
|
||||
// Parse metadata values to JSON and make TypeScript happy
|
||||
metadata: convertRecordToJsonSchema(
|
||||
(trace.metadata as Record<string, string>) || {},
|
||||
) as JsonValue,
|
||||
...convertClickhouseToDomain(trace),
|
||||
observations: trace.observations,
|
||||
scores: trace.scores,
|
||||
totalCost: trace.totalCost,
|
||||
latency: trace.latency,
|
||||
htmlPath: trace.htmlPath,
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ const APIComment = z
|
||||
updatedAt: z.coerce.date(),
|
||||
objectType: z.nativeEnum(CommentObjectType),
|
||||
objectId: z.string(),
|
||||
content: z.string().min(1).max(500),
|
||||
content: z.string().min(1).max(3000),
|
||||
authorUserId: z.string().nullish(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { getFileExtensionFromContentType } from "@/src/features/media/server/getFileExtensionFromContentType";
|
||||
@@ -14,10 +15,8 @@ import {
|
||||
ForbiddenError,
|
||||
InternalServerError,
|
||||
InvalidRequestError,
|
||||
Prisma,
|
||||
} from "@langfuse/shared";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { instrumentAsync } from "@langfuse/shared/src/server";
|
||||
import { logger } from "@langfuse/shared/src/server";
|
||||
|
||||
export default withMiddlewares({
|
||||
@@ -45,158 +44,120 @@ export default withMiddlewares({
|
||||
`File size must be less than ${env.LANGFUSE_S3_MEDIA_MAX_CONTENT_LENGTH} bytes`,
|
||||
);
|
||||
|
||||
// Retry up to 3 times to avoid race conditions for concurrent media uploads
|
||||
// Multiple observations can reference the same media, so we need to make sure no deadlock occurs
|
||||
const MAX_RETRIES = 6;
|
||||
let retries = 0;
|
||||
try {
|
||||
const existingMedia = await prisma.media.findUnique({
|
||||
where: {
|
||||
projectId_sha256Hash: {
|
||||
projectId,
|
||||
sha256Hash,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return await instrumentAsync(
|
||||
{ name: "media-create-upload-url" },
|
||||
async (span) => {
|
||||
span.setAttribute("projectId", projectId);
|
||||
span.setAttribute("traceId", traceId);
|
||||
span.setAttribute("observationId", observationId ?? "");
|
||||
span.setAttribute("field", field);
|
||||
span.setAttribute("sha256Hash", sha256Hash);
|
||||
|
||||
while (retries < MAX_RETRIES) {
|
||||
try {
|
||||
if (retries > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
return await prisma.$transaction<{
|
||||
mediaId: string;
|
||||
uploadUrl: string | null;
|
||||
}>(
|
||||
async (tx) => {
|
||||
const existingMedia = await prisma.media.findUnique({
|
||||
where: {
|
||||
projectId_sha256Hash: {
|
||||
projectId,
|
||||
sha256Hash,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
existingMedia &&
|
||||
existingMedia.uploadHttpStatus === 200 &&
|
||||
existingMedia.contentType === contentType
|
||||
) {
|
||||
if (observationId) {
|
||||
// Use raw upserts to avoid deadlocks
|
||||
await tx.$queryRaw`
|
||||
INSERT INTO "observation_media" ("id", "project_id", "trace_id", "observation_id", "media_id", "field")
|
||||
VALUES (${randomUUID()}, ${projectId}, ${traceId}, ${observationId}, ${existingMedia.id}, ${field})
|
||||
ON CONFLICT DO NOTHING;
|
||||
`;
|
||||
} else {
|
||||
// Use raw upserts to avoid deadlocks
|
||||
await tx.$queryRaw`
|
||||
INSERT INTO "trace_media" ("id", "project_id", "trace_id", "media_id", "field")
|
||||
VALUES (${randomUUID()}, ${projectId}, ${traceId}, ${existingMedia.id}, ${field})
|
||||
ON CONFLICT DO NOTHING;
|
||||
`;
|
||||
}
|
||||
|
||||
return {
|
||||
mediaId: existingMedia.id,
|
||||
uploadUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
const mediaId = existingMedia?.id ?? randomUUID();
|
||||
span.setAttribute("mediaId", mediaId);
|
||||
|
||||
if (!env.LANGFUSE_S3_MEDIA_UPLOAD_BUCKET)
|
||||
throw new InternalServerError(
|
||||
"Media upload to blob storage not enabled or no bucket configured",
|
||||
);
|
||||
|
||||
const s3Client = getMediaStorageServiceClient(
|
||||
env.LANGFUSE_S3_MEDIA_UPLOAD_BUCKET,
|
||||
);
|
||||
|
||||
const bucketPath = getBucketPath({
|
||||
projectId,
|
||||
mediaId,
|
||||
contentType,
|
||||
});
|
||||
|
||||
const uploadUrl = await s3Client.getSignedUploadUrl({
|
||||
path: bucketPath,
|
||||
ttlSeconds: 60 * 60, // 1 hour
|
||||
sha256Hash,
|
||||
contentType,
|
||||
contentLength,
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
// Use raw upserts to avoid deadlocks
|
||||
tx.$queryRaw`
|
||||
INSERT INTO "media" (
|
||||
"id",
|
||||
"project_id",
|
||||
"sha_256_hash",
|
||||
"bucket_path",
|
||||
"bucket_name",
|
||||
"content_type",
|
||||
"content_length"
|
||||
)
|
||||
VALUES (
|
||||
${mediaId},
|
||||
${projectId},
|
||||
${sha256Hash},
|
||||
${bucketPath},
|
||||
${env.LANGFUSE_S3_MEDIA_UPLOAD_BUCKET},
|
||||
${contentType},
|
||||
${contentLength}
|
||||
)
|
||||
ON CONFLICT ("project_id", "sha_256_hash")
|
||||
DO UPDATE SET
|
||||
"bucket_name" = ${env.LANGFUSE_S3_MEDIA_UPLOAD_BUCKET},
|
||||
"bucket_path" = ${bucketPath},
|
||||
"content_type" = ${contentType},
|
||||
"content_length" = ${contentLength}
|
||||
`,
|
||||
observationId
|
||||
? tx.$queryRaw`
|
||||
INSERT INTO "observation_media" ("id", "project_id", "trace_id", "observation_id", "media_id", "field")
|
||||
VALUES (${randomUUID()}, ${projectId}, ${traceId}, ${observationId}, ${mediaId}, ${field})
|
||||
ON CONFLICT DO NOTHING;
|
||||
`
|
||||
: tx.$queryRaw`
|
||||
INSERT INTO "trace_media" ("id", "project_id", "trace_id", "media_id", "field")
|
||||
VALUES (${randomUUID()}, ${projectId}, ${traceId}, ${mediaId}, ${field})
|
||||
ON CONFLICT DO NOTHING;
|
||||
`,
|
||||
]);
|
||||
|
||||
return {
|
||||
mediaId,
|
||||
uploadUrl,
|
||||
};
|
||||
},
|
||||
{
|
||||
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to get media upload URL for trace ${traceId} and observation ${observationId}. Retrying...`,
|
||||
error,
|
||||
);
|
||||
retries++;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
existingMedia &&
|
||||
existingMedia.uploadHttpStatus === 200 &&
|
||||
existingMedia.contentType === contentType
|
||||
) {
|
||||
if (observationId) {
|
||||
// Use raw upserts to avoid deadlocks
|
||||
await prisma.$queryRaw`
|
||||
INSERT INTO "observation_media" ("id", "project_id", "trace_id", "observation_id", "media_id", "field")
|
||||
VALUES (${randomUUID()}, ${projectId}, ${traceId}, ${observationId}, ${existingMedia.id}, ${field})
|
||||
ON CONFLICT DO NOTHING;
|
||||
`;
|
||||
} else {
|
||||
// Use raw upserts to avoid deadlocks
|
||||
await prisma.$queryRaw`
|
||||
INSERT INTO "trace_media" ("id", "project_id", "trace_id", "media_id", "field")
|
||||
VALUES (${randomUUID()}, ${projectId}, ${traceId}, ${existingMedia.id}, ${field})
|
||||
ON CONFLICT DO NOTHING;
|
||||
`;
|
||||
}
|
||||
logger.error(
|
||||
`Failed to get media upload URL for trace ${traceId} and observation ${observationId}.`,
|
||||
|
||||
return {
|
||||
mediaId: existingMedia.id,
|
||||
uploadUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
const mediaId = getMediaId({ projectId, sha256Hash });
|
||||
|
||||
if (!env.LANGFUSE_S3_MEDIA_UPLOAD_BUCKET)
|
||||
throw new InternalServerError(
|
||||
"Media upload to blob storage not enabled or no bucket configured",
|
||||
);
|
||||
throw new InternalServerError("Failed to get media upload URL");
|
||||
},
|
||||
);
|
||||
|
||||
const s3Client = getMediaStorageServiceClient(
|
||||
env.LANGFUSE_S3_MEDIA_UPLOAD_BUCKET,
|
||||
);
|
||||
|
||||
const bucketPath = getBucketPath({
|
||||
projectId,
|
||||
mediaId,
|
||||
contentType,
|
||||
});
|
||||
|
||||
const uploadUrl = await s3Client.getSignedUploadUrl({
|
||||
path: bucketPath,
|
||||
ttlSeconds: 60 * 60, // 1 hour
|
||||
sha256Hash,
|
||||
contentType,
|
||||
contentLength,
|
||||
});
|
||||
|
||||
// Create media record first to ensure fkey constraint is met on next queries
|
||||
await prisma.$queryRaw`
|
||||
INSERT INTO "media" (
|
||||
"id",
|
||||
"project_id",
|
||||
"sha_256_hash",
|
||||
"bucket_path",
|
||||
"bucket_name",
|
||||
"content_type",
|
||||
"content_length"
|
||||
)
|
||||
VALUES (
|
||||
${mediaId},
|
||||
${projectId},
|
||||
${sha256Hash},
|
||||
${bucketPath},
|
||||
${env.LANGFUSE_S3_MEDIA_UPLOAD_BUCKET},
|
||||
${contentType},
|
||||
${contentLength}
|
||||
)
|
||||
ON CONFLICT ("project_id", "sha_256_hash")
|
||||
DO UPDATE SET
|
||||
"bucket_name" = ${env.LANGFUSE_S3_MEDIA_UPLOAD_BUCKET},
|
||||
"bucket_path" = ${bucketPath},
|
||||
"content_type" = ${contentType},
|
||||
"content_length" = ${contentLength}
|
||||
`;
|
||||
|
||||
if (observationId) {
|
||||
await prisma.$queryRaw`
|
||||
INSERT INTO "observation_media" ("id", "project_id", "trace_id", "observation_id", "media_id", "field")
|
||||
VALUES (${randomUUID()}, ${projectId}, ${traceId}, ${observationId}, ${mediaId}, ${field})
|
||||
ON CONFLICT DO NOTHING;
|
||||
`;
|
||||
} else {
|
||||
await prisma.$queryRaw`
|
||||
INSERT INTO "trace_media" ("id", "project_id", "trace_id", "media_id", "field")
|
||||
VALUES (${randomUUID()}, ${projectId}, ${traceId}, ${mediaId}, ${field})
|
||||
ON CONFLICT DO NOTHING;
|
||||
`;
|
||||
}
|
||||
|
||||
return {
|
||||
mediaId,
|
||||
uploadUrl,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to get media upload URL for trace ${traceId} and observation ${observationId}.`,
|
||||
);
|
||||
throw new InternalServerError("Failed to get media upload URL");
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -215,3 +176,13 @@ function getBucketPath(params: {
|
||||
|
||||
return `${prefix}${projectId}/${mediaId}.${fileExtension}`;
|
||||
}
|
||||
|
||||
function getMediaId(params: { projectId: string; sha256Hash: string }) {
|
||||
const { projectId, sha256Hash } = params;
|
||||
|
||||
return crypto
|
||||
.createHash("sha256")
|
||||
.update(projectId + sha256Hash, "utf8")
|
||||
.digest("base64url")
|
||||
.slice(0, 22);
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { promptVersionHandler as default } from "@/src/features/prompts/server/handlers/promptVersionHandler";
|
||||
@@ -32,6 +32,7 @@ import { Shield } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { captureException } from "@sentry/nextjs";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { openChat } from "@/src/features/support-chat/chat";
|
||||
|
||||
const credentialAuthForm = z.object({
|
||||
email: z.string().email(),
|
||||
@@ -435,6 +436,20 @@ export default function SignIn({
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined && (
|
||||
<div className="-mb-4 mt-4 rounded-lg bg-card p-3 text-center text-sm sm:mx-auto sm:w-full sm:max-w-[480px] sm:rounded-lg sm:px-6">
|
||||
If you are experiencing issues signing in, please force refresh this
|
||||
page (CMD + SHIFT + R) or clear your browser cache. We are working
|
||||
on a solution.{" "}
|
||||
<span
|
||||
className="cursor-pointer whitespace-nowrap text-xs font-medium text-primary-accent hover:text-hover-primary-accent"
|
||||
onClick={openChat}
|
||||
>
|
||||
(contact us)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CloudRegionSwitch />
|
||||
|
||||
<div className="mt-14 bg-background px-6 py-10 shadow sm:mx-auto sm:w-full sm:max-w-[480px] sm:rounded-lg sm:px-10">
|
||||
|
||||
@@ -23,11 +23,14 @@ import { ActionButton } from "@/src/components/ActionButton";
|
||||
import { BatchExportsSettingsPage } from "@/src/features/batch-exports/components/BatchExportsSettingsPage";
|
||||
import { AuditLogsSettingsPage } from "@/src/ee/features/audit-log-viewer/AuditLogsSettingsPage";
|
||||
import { ModelsSettings } from "@/src/features/models/components/ModelSettings";
|
||||
import ConfigureRetention from "@/src/features/projects/components/ConfigureRetention";
|
||||
import { env } from "@/src/env.mjs";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { project, organization } = useQueryProject();
|
||||
const router = useRouter();
|
||||
const showBillingSettings = useHasEntitlement("cloud-billing");
|
||||
const isLangfuseCloud = Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION);
|
||||
if (!project || !organization) return null;
|
||||
return (
|
||||
<div className="lg:container">
|
||||
@@ -42,6 +45,7 @@ export default function SettingsPage() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<HostNameProject />
|
||||
<RenameProject />
|
||||
{isLangfuseCloud && <ConfigureRetention />}
|
||||
<div>
|
||||
<Header title="Debug Information" level="h3" />
|
||||
<JSONView
|
||||
|
||||
+41
-3
@@ -184,15 +184,16 @@ if (
|
||||
clientId: env.AUTH_CUSTOM_CLIENT_ID,
|
||||
clientSecret: env.AUTH_CUSTOM_CLIENT_SECRET,
|
||||
issuer: env.AUTH_CUSTOM_ISSUER,
|
||||
idToken: env.AUTH_CUSTOM_ID_TOKEN !== "false", // defaults to true
|
||||
allowDangerousEmailAccountLinking:
|
||||
env.AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING === "true",
|
||||
authorization: {
|
||||
params: { scope: env.AUTH_CUSTOM_SCOPE ?? "openid email profile" },
|
||||
},
|
||||
client: {
|
||||
token_endpoint_auth_method:
|
||||
env.AUTH_CUSTOM_CLIENT_AUTH_METHOD ?? "client_secret_basic",
|
||||
token_endpoint_auth_method: env.AUTH_CUSTOM_CLIENT_AUTH_METHOD,
|
||||
},
|
||||
checks: env.AUTH_CUSTOM_CHECKS,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -203,6 +204,10 @@ if (env.AUTH_GOOGLE_CLIENT_ID && env.AUTH_GOOGLE_CLIENT_SECRET)
|
||||
clientSecret: env.AUTH_GOOGLE_CLIENT_SECRET,
|
||||
allowDangerousEmailAccountLinking:
|
||||
env.AUTH_GOOGLE_ALLOW_ACCOUNT_LINKING === "true",
|
||||
client: {
|
||||
token_endpoint_auth_method: env.AUTH_GOOGLE_CLIENT_AUTH_METHOD,
|
||||
},
|
||||
checks: env.AUTH_GOOGLE_CHECKS,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -218,6 +223,10 @@ if (
|
||||
issuer: env.AUTH_OKTA_ISSUER,
|
||||
allowDangerousEmailAccountLinking:
|
||||
env.AUTH_OKTA_ALLOW_ACCOUNT_LINKING === "true",
|
||||
client: {
|
||||
token_endpoint_auth_method: env.AUTH_OKTA_CLIENT_AUTH_METHOD,
|
||||
},
|
||||
checks: env.AUTH_OKTA_CHECKS,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -233,6 +242,10 @@ if (
|
||||
issuer: env.AUTH_AUTH0_ISSUER,
|
||||
allowDangerousEmailAccountLinking:
|
||||
env.AUTH_AUTH0_ALLOW_ACCOUNT_LINKING === "true",
|
||||
client: {
|
||||
token_endpoint_auth_method: env.AUTH_AUTH0_CLIENT_AUTH_METHOD,
|
||||
},
|
||||
checks: env.AUTH_AUTH0_CHECKS,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -243,6 +256,10 @@ if (env.AUTH_GITHUB_CLIENT_ID && env.AUTH_GITHUB_CLIENT_SECRET)
|
||||
clientSecret: env.AUTH_GITHUB_CLIENT_SECRET,
|
||||
allowDangerousEmailAccountLinking:
|
||||
env.AUTH_GITHUB_ALLOW_ACCOUNT_LINKING === "true",
|
||||
client: {
|
||||
token_endpoint_auth_method: env.AUTH_GITHUB_CLIENT_AUTH_METHOD,
|
||||
},
|
||||
checks: env.AUTH_GITHUB_CHECKS,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -258,6 +275,11 @@ if (
|
||||
enterprise: { baseUrl: env.AUTH_GITHUB_ENTERPRISE_BASE_URL },
|
||||
allowDangerousEmailAccountLinking:
|
||||
env.AUTH_GITHUB_ENTERPRISE_ALLOW_ACCOUNT_LINKING === "true",
|
||||
client: {
|
||||
token_endpoint_auth_method:
|
||||
env.AUTH_GITHUB_ENTERPRISE_CLIENT_AUTH_METHOD,
|
||||
},
|
||||
checks: env.AUTH_GITHUB_ENTERPRISE_CHECKS,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -270,6 +292,10 @@ if (env.AUTH_GITLAB_CLIENT_ID && env.AUTH_GITLAB_CLIENT_SECRET)
|
||||
allowDangerousEmailAccountLinking:
|
||||
env.AUTH_GITLAB_ALLOW_ACCOUNT_LINKING === "true",
|
||||
issuer: env.AUTH_GITLAB_ISSUER,
|
||||
client: {
|
||||
token_endpoint_auth_method: env.AUTH_GITLAB_CLIENT_AUTH_METHOD,
|
||||
},
|
||||
checks: env.AUTH_GITLAB_CHECKS,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -285,6 +311,10 @@ if (
|
||||
tenantId: env.AUTH_AZURE_AD_TENANT_ID,
|
||||
allowDangerousEmailAccountLinking:
|
||||
env.AUTH_AZURE_ALLOW_ACCOUNT_LINKING === "true",
|
||||
client: {
|
||||
token_endpoint_auth_method: env.AUTH_AZURE_CLIENT_AUTH_METHOD,
|
||||
},
|
||||
checks: env.AUTH_AZURE_CHECKS,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -298,9 +328,12 @@ if (
|
||||
clientId: env.AUTH_COGNITO_CLIENT_ID,
|
||||
clientSecret: env.AUTH_COGNITO_CLIENT_SECRET,
|
||||
issuer: env.AUTH_COGNITO_ISSUER,
|
||||
checks: "nonce",
|
||||
checks: env.AUTH_COGNITO_CHECKS ?? "nonce",
|
||||
allowDangerousEmailAccountLinking:
|
||||
env.AUTH_COGNITO_ALLOW_ACCOUNT_LINKING === "true",
|
||||
client: {
|
||||
token_endpoint_auth_method: env.AUTH_COGNITO_CLIENT_AUTH_METHOD,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -316,6 +349,10 @@ if (
|
||||
issuer: env.AUTH_KEYCLOAK_ISSUER,
|
||||
allowDangerousEmailAccountLinking:
|
||||
env.AUTH_KEYCLOAK_ALLOW_ACCOUNT_LINKING === "true",
|
||||
client: {
|
||||
token_endpoint_auth_method: env.AUTH_KEYCLOAK_CLIENT_AUTH_METHOD,
|
||||
},
|
||||
checks: env.AUTH_KEYCLOAK_CHECKS,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -469,6 +506,7 @@ export async function getAuthOptions(): Promise<NextAuthOptions> {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
role: projectRole,
|
||||
retentionDays: project.retentionDays,
|
||||
deletedAt: project.deletedAt,
|
||||
};
|
||||
})
|
||||
|
||||
Vendored
+1
@@ -45,6 +45,7 @@ declare module "next-auth" {
|
||||
id: PrismaProject["id"];
|
||||
name: PrismaProject["name"];
|
||||
deletedAt: PrismaProject["deletedAt"];
|
||||
retentionDays: PrismaProject["retentionDays"];
|
||||
role: Role; // include only projects where user has a role
|
||||
}[];
|
||||
}[];
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "worker",
|
||||
"version": "3.13.0",
|
||||
"version": "3.17.0",
|
||||
"description": "",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { expect, test, describe, beforeAll } from "vitest";
|
||||
import { env } from "../env";
|
||||
import { randomUUID } from "crypto";
|
||||
import {
|
||||
StorageService,
|
||||
StorageServiceFactory,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { handleDataRetentionProcessingJob } from "../ee/dataRetention/handleDataRetentionProcessingJob";
|
||||
import { Job } from "bullmq";
|
||||
|
||||
describe("DataRetentionProcessingJob", () => {
|
||||
let storageService: StorageService;
|
||||
const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
|
||||
|
||||
beforeAll(() => {
|
||||
storageService = StorageServiceFactory.getInstance({
|
||||
accessKeyId: env.LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID,
|
||||
secretAccessKey: env.LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY,
|
||||
bucketName: env.LANGFUSE_S3_MEDIA_UPLOAD_BUCKET,
|
||||
endpoint: env.LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT,
|
||||
region: env.LANGFUSE_S3_MEDIA_UPLOAD_REGION,
|
||||
forcePathStyle: env.LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE === "true",
|
||||
});
|
||||
});
|
||||
|
||||
test("should delete media files from cloud storage and database if expired", async () => {
|
||||
// Setup
|
||||
const fileName = `${randomUUID()}.txt`;
|
||||
const fileType = "text/plain";
|
||||
const data = "Hello, world!";
|
||||
const expiresInSeconds = 3600;
|
||||
await storageService.uploadFile({
|
||||
fileName,
|
||||
fileType,
|
||||
data,
|
||||
expiresInSeconds,
|
||||
});
|
||||
|
||||
const mediaId = randomUUID();
|
||||
const traceId = randomUUID();
|
||||
await prisma.media.create({
|
||||
data: {
|
||||
id: mediaId,
|
||||
sha256Hash: randomUUID(),
|
||||
projectId,
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30), // 30 days in the past
|
||||
bucketPath: fileName,
|
||||
bucketName: env.LANGFUSE_S3_MEDIA_UPLOAD_BUCKET,
|
||||
contentType: fileType,
|
||||
contentLength: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.traceMedia.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
projectId,
|
||||
traceId,
|
||||
mediaId,
|
||||
field: "test",
|
||||
},
|
||||
});
|
||||
|
||||
// When
|
||||
await handleDataRetentionProcessingJob({
|
||||
data: { payload: { projectId, retention: 7 } }, // Delete after 7 days
|
||||
} as Job);
|
||||
|
||||
// Then
|
||||
const files = await storageService.listFiles("");
|
||||
expect(files).not.toContain(fileName);
|
||||
|
||||
const media = await prisma.media.findUnique({
|
||||
where: { id: mediaId },
|
||||
});
|
||||
expect(media).toBeNull();
|
||||
|
||||
const traceMedia = await prisma.traceMedia.findFirst({
|
||||
where: { mediaId },
|
||||
});
|
||||
expect(traceMedia).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -384,6 +384,7 @@ describe("create experiment job calls with langfuse server side tracing", async
|
||||
secretKey: encrypt("test-key"),
|
||||
baseURL: null,
|
||||
customModels: [],
|
||||
extraHeaderKeys: [],
|
||||
withDefaultModels: true,
|
||||
config: null,
|
||||
} as any);
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { expect, test, describe, beforeAll } from "vitest";
|
||||
import { env } from "../env";
|
||||
import { randomUUID } from "crypto";
|
||||
import {
|
||||
StorageService,
|
||||
StorageServiceFactory,
|
||||
} from "@langfuse/shared/src/server";
|
||||
|
||||
describe("StorageService", () => {
|
||||
let storageService: StorageService;
|
||||
const baseUrl = `${env.LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT}/${env.LANGFUSE_S3_EVENT_UPLOAD_BUCKET}`;
|
||||
|
||||
beforeAll(() => {
|
||||
storageService = StorageServiceFactory.getInstance({
|
||||
accessKeyId: env.LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID,
|
||||
secretAccessKey: env.LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY,
|
||||
bucketName: env.LANGFUSE_S3_EVENT_UPLOAD_BUCKET,
|
||||
endpoint: env.LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT,
|
||||
region: env.LANGFUSE_S3_EVENT_UPLOAD_REGION,
|
||||
forcePathStyle: env.LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE === "true",
|
||||
});
|
||||
});
|
||||
|
||||
test("uploadFile should upload a file and return a signed URL", async () => {
|
||||
// Setup
|
||||
const fileName = `${randomUUID()}.txt`;
|
||||
const fileType = "text/plain";
|
||||
const data = "Hello, world!";
|
||||
const expiresInSeconds = 3600;
|
||||
|
||||
// When
|
||||
const result = await storageService.uploadFile({
|
||||
fileName,
|
||||
fileType,
|
||||
data,
|
||||
expiresInSeconds,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.signedUrl).toContain(`${baseUrl}/${fileName}`);
|
||||
const file = await storageService.download(fileName);
|
||||
expect(file).toBe(data);
|
||||
});
|
||||
|
||||
test("uploadJson should upload a JSON file", async () => {
|
||||
// Setup
|
||||
const fileName = `${randomUUID()}.json`;
|
||||
const data = [{ hello: "world" }];
|
||||
const expiresInSeconds = 3600;
|
||||
|
||||
// When
|
||||
await storageService.uploadJson(fileName, data);
|
||||
|
||||
// Then
|
||||
const file = await storageService.download(fileName);
|
||||
expect(JSON.parse(file)).toEqual(data);
|
||||
});
|
||||
|
||||
test("listFiles should list files in the bucket", async () => {
|
||||
// Setup
|
||||
const fileName1 = `${randomUUID()}.txt`;
|
||||
const fileName2 = `${randomUUID()}.txt`;
|
||||
await storageService.uploadJson(fileName1, [{ hello: "world" }]);
|
||||
await storageService.uploadJson(fileName2, [{ hello: "world" }]);
|
||||
|
||||
// When
|
||||
const files = await storageService.listFiles("");
|
||||
|
||||
// Then
|
||||
const fileNames = files.map((f) => f.file);
|
||||
expect(fileNames).toContain(fileName1);
|
||||
expect(fileNames).toContain(fileName2);
|
||||
});
|
||||
|
||||
test("deleteFiles should delete a file", async () => {
|
||||
// Setup
|
||||
const fileName1 = `${randomUUID()}.txt`;
|
||||
const fileName2 = `${randomUUID()}.txt`;
|
||||
await storageService.uploadJson(fileName1, [{ hello: "world" }]);
|
||||
await storageService.uploadJson(fileName2, [{ hello: "world" }]);
|
||||
|
||||
// When
|
||||
await storageService.deleteFiles([fileName1]);
|
||||
|
||||
// Then
|
||||
const files = await storageService.listFiles("");
|
||||
const fileNames = files.map((f) => f.file);
|
||||
expect(fileNames).not.toContain(fileName1);
|
||||
});
|
||||
});
|
||||
+26
-4
@@ -20,11 +20,12 @@ import helmet from "helmet";
|
||||
import { cloudUsageMeteringQueueProcessor } from "./queues/cloudUsageMeteringQueue";
|
||||
import { WorkerManager } from "./queues/workerManager";
|
||||
import {
|
||||
CoreDataS3ExportQueue,
|
||||
DataRetentionQueue,
|
||||
MeteringDataPostgresExportQueue,
|
||||
PostHogIntegrationQueue,
|
||||
QueueName,
|
||||
logger,
|
||||
PostHogIntegrationQueue,
|
||||
CoreDataS3ExportQueue,
|
||||
MeteringDataPostgresExportQueue,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { env } from "./env";
|
||||
import { ingestionQueueProcessorBuilder } from "./queues/ingestionQueue";
|
||||
@@ -38,6 +39,10 @@ import {
|
||||
} from "./queues/postHogIntegrationQueue";
|
||||
import { coreDataS3ExportProcessor } from "./queues/coreDataS3ExportQueue";
|
||||
import { meteringDataPostgresExportProcessor } from "./ee/meteringDataPostgresExport/handleMeteringDataPostgresExportJob";
|
||||
import {
|
||||
dataRetentionProcessingProcessor,
|
||||
dataRetentionProcessor,
|
||||
} from "./queues/dataRetentionQueue";
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -160,7 +165,7 @@ if (env.QUEUE_CONSUMER_INGESTION_QUEUE_IS_ENABLED === "true") {
|
||||
);
|
||||
}
|
||||
|
||||
if (env.QUEUE_CONSUMER_INGESTION_QUEUE_IS_ENABLED === "true") {
|
||||
if (env.QUEUE_CONSUMER_INGESTION_SECONDARY_QUEUE_IS_ENABLED === "true") {
|
||||
WorkerManager.register(
|
||||
QueueName.IngestionSecondaryQueue,
|
||||
ingestionQueueProcessorBuilder(false),
|
||||
@@ -220,6 +225,23 @@ if (env.QUEUE_CONSUMER_POSTHOG_INTEGRATION_QUEUE_IS_ENABLED === "true") {
|
||||
);
|
||||
}
|
||||
|
||||
if (env.QUEUE_CONSUMER_DATA_RETENTION_QUEUE_IS_ENABLED === "true") {
|
||||
// Instantiate the queue to trigger scheduled jobs
|
||||
DataRetentionQueue.getInstance();
|
||||
|
||||
WorkerManager.register(QueueName.DataRetentionQueue, dataRetentionProcessor, {
|
||||
concurrency: 1,
|
||||
});
|
||||
|
||||
WorkerManager.register(
|
||||
QueueName.DataRetentionProcessingQueue,
|
||||
dataRetentionProcessingProcessor,
|
||||
{
|
||||
concurrency: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => onShutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => onShutdown("SIGTERM"));
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const VERSION = "v3.13.0";
|
||||
export const VERSION = "v3.17.0";
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
logger,
|
||||
StorageService,
|
||||
StorageServiceFactory,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { Job } from "bullmq";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { env } from "../../env";
|
||||
|
||||
let s3MediaStorageClient: StorageService;
|
||||
|
||||
const getS3MediaStorageClient = (bucketName: string): StorageService => {
|
||||
if (!s3MediaStorageClient) {
|
||||
s3MediaStorageClient = StorageServiceFactory.getInstance({
|
||||
bucketName,
|
||||
accessKeyId: env.LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID,
|
||||
secretAccessKey: env.LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY,
|
||||
endpoint: env.LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT,
|
||||
region: env.LANGFUSE_S3_MEDIA_UPLOAD_REGION,
|
||||
forcePathStyle: env.LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE === "true",
|
||||
});
|
||||
}
|
||||
return s3MediaStorageClient;
|
||||
};
|
||||
|
||||
export const handleDataRetentionProcessingJob = async (job: Job) => {
|
||||
const { projectId, retention } = job.data.payload;
|
||||
|
||||
// Delete media files if bucket is configured
|
||||
if (env.LANGFUSE_S3_MEDIA_UPLOAD_BUCKET) {
|
||||
logger.info(
|
||||
`[Data Retention] Deleting media files older than ${retention} days for project ${projectId}`,
|
||||
);
|
||||
const mediaFilesToDelete = await prisma.media.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
projectId: true,
|
||||
createdAt: true,
|
||||
bucketPath: true,
|
||||
bucketName: true,
|
||||
},
|
||||
where: {
|
||||
projectId,
|
||||
createdAt: {
|
||||
lte: new Date(Date.now() - retention * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
},
|
||||
});
|
||||
const mediaStorageClient = getS3MediaStorageClient(
|
||||
env.LANGFUSE_S3_MEDIA_UPLOAD_BUCKET,
|
||||
);
|
||||
// Delete from Cloud Storage
|
||||
await mediaStorageClient.deleteFiles(
|
||||
mediaFilesToDelete.map((f) => f.bucketPath),
|
||||
);
|
||||
// Delete from postgres. We should automatically remove the corresponding traceMedia and observationMedia
|
||||
await prisma.media.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: mediaFilesToDelete.map((f) => f.id),
|
||||
},
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
logger.info(
|
||||
`[Data Retention] Deleted ${mediaFilesToDelete.length} media files for project ${projectId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Delete ClickHouse (TTL / Delete Queries)
|
||||
|
||||
// Set S3 Lifecycle for deletion (Future)
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Job } from "bullmq";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import {
|
||||
DataRetentionProcessingQueue,
|
||||
QueueJobs,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
export const handleDataRetentionSchedule = async (job: Job) => {
|
||||
const projectsWithRetention = await prisma.project.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
retentionDays: true,
|
||||
},
|
||||
where: {
|
||||
retentionDays: {
|
||||
gt: 0, // Select all projects with a non-zero/non-null retention
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const dataRetentionProcessingQueue =
|
||||
DataRetentionProcessingQueue.getInstance();
|
||||
if (!dataRetentionProcessingQueue) {
|
||||
throw new Error("DataRetentionProcessingQueue not initialized");
|
||||
}
|
||||
|
||||
await dataRetentionProcessingQueue.addBulk(
|
||||
projectsWithRetention.map((project) => ({
|
||||
name: QueueJobs.DataRetentionProcessingJob,
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
name: QueueJobs.DataRetentionProcessingJob,
|
||||
timestamp: new Date(),
|
||||
payload: {
|
||||
projectId: project.id,
|
||||
retention: project.retentionDays,
|
||||
},
|
||||
},
|
||||
})),
|
||||
);
|
||||
};
|
||||
@@ -153,6 +153,12 @@ const EnvSchema = z.object({
|
||||
QUEUE_CONSUMER_POSTHOG_INTEGRATION_QUEUE_IS_ENABLED: z
|
||||
.enum(["true", "false"])
|
||||
.default("true"),
|
||||
QUEUE_CONSUMER_INGESTION_SECONDARY_QUEUE_IS_ENABLED: z
|
||||
.enum(["true", "false"])
|
||||
.default("true"),
|
||||
QUEUE_CONSUMER_DATA_RETENTION_QUEUE_IS_ENABLED: z
|
||||
.enum(["true", "false"])
|
||||
.default("true"),
|
||||
|
||||
// Core data S3 upload - Langfuse Cloud
|
||||
LANGFUSE_S3_CORE_DATA_EXPORT_IS_ENABLED: z
|
||||
@@ -168,6 +174,17 @@ const EnvSchema = z.object({
|
||||
.enum(["true", "false"])
|
||||
.default("false"),
|
||||
|
||||
// Media upload
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_BUCKET: z.string().optional(),
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_PREFIX: z.string().default(""),
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_REGION: z.string().optional(),
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: z.string().optional(),
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID: z.string().optional(),
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY: z.string().optional(),
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: z
|
||||
.enum(["true", "false"])
|
||||
.default("false"),
|
||||
|
||||
// Metering data Postgres export - Langfuse Cloud
|
||||
LANGFUSE_POSTGRES_METERING_DATA_EXPORT_IS_ENABLED: z
|
||||
.enum(["true", "false"])
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
BatchExportStatus,
|
||||
exportOptions,
|
||||
FilterCondition,
|
||||
Prisma,
|
||||
Score,
|
||||
TimeFilter,
|
||||
} from "@langfuse/shared";
|
||||
@@ -306,6 +305,10 @@ export const getDatabaseReadStream = async ({
|
||||
outputCost: metric?.calculatedOutputCost,
|
||||
totalCost: metric?.calculatedTotalCost,
|
||||
level: metric?.level,
|
||||
errorCount: metric?.errorCount,
|
||||
warningCount: metric?.warningCount,
|
||||
defaultCount: metric?.defaultCount,
|
||||
debugCount: metric?.debugCount,
|
||||
observationCount: Number(metric?.observationCount),
|
||||
scores: outputScores,
|
||||
inputTokens: metric?.promptTokens,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
ChatMessage,
|
||||
decryptAndParseExtraHeaders,
|
||||
fetchLLMCompletion,
|
||||
logger,
|
||||
type TraceParams,
|
||||
@@ -24,6 +25,7 @@ export async function callStructuredLLM<T extends ZodSchema>(
|
||||
const { completion, processTracedEvents } = await fetchLLMCompletion({
|
||||
streaming: false,
|
||||
apiKey: decrypt(llmApiKey.secretKey), // decrypt the secret key
|
||||
extraHeaders: decryptAndParseExtraHeaders(llmApiKey.extraHeaders),
|
||||
baseURL: llmApiKey.baseURL || undefined,
|
||||
messages,
|
||||
modelParams: {
|
||||
@@ -67,6 +69,7 @@ export async function callLLM(
|
||||
const { completion, processTracedEvents } = await fetchLLMCompletion({
|
||||
streaming: false,
|
||||
apiKey: decrypt(llmApiKey.secretKey), // decrypt the secret key
|
||||
extraHeaders: decryptAndParseExtraHeaders(llmApiKey.extraHeaders),
|
||||
baseURL: llmApiKey.baseURL || undefined,
|
||||
messages,
|
||||
modelParams: {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Processor } from "bullmq";
|
||||
import { logger, QueueJobs } from "@langfuse/shared/src/server";
|
||||
import { handleDataRetentionSchedule } from "../ee/dataRetention/handleDataRetentionSchedule";
|
||||
import { handleDataRetentionProcessingJob } from "../ee/dataRetention/handleDataRetentionProcessingJob";
|
||||
|
||||
export const dataRetentionProcessor: Processor = async (job) => {
|
||||
if (job.name === QueueJobs.DataRetentionJob) {
|
||||
logger.info("Executing Data Retention Job");
|
||||
try {
|
||||
return await handleDataRetentionSchedule(job);
|
||||
} catch (error) {
|
||||
logger.error("Error executing DataRetentionJob", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const dataRetentionProcessingProcessor: Processor = async (job) => {
|
||||
if (job.name === QueueJobs.DataRetentionProcessingJob) {
|
||||
try {
|
||||
return await handleDataRetentionProcessingJob(job);
|
||||
} catch (error) {
|
||||
logger.error("Error executing DataRetentionProcessingJob", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
getClickhouseEntityType,
|
||||
getCurrentSpan,
|
||||
getQueue,
|
||||
recordDistribution,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
|
||||
@@ -93,9 +94,24 @@ export const ingestionQueueProcessorBuilder = (
|
||||
);
|
||||
|
||||
// Download all events from folder into a local array
|
||||
const eventFiles = await s3Client.listFiles(
|
||||
`${env.LANGFUSE_S3_EVENT_UPLOAD_PREFIX}${job.data.payload.authCheck.scope.projectId}/${getClickhouseEntityType(job.data.payload.data.type)}/${job.data.payload.data.eventBodyId}/`,
|
||||
const clickhouseEntityType = getClickhouseEntityType(
|
||||
job.data.payload.data.type,
|
||||
);
|
||||
const eventFiles = await s3Client.listFiles(
|
||||
`${env.LANGFUSE_S3_EVENT_UPLOAD_PREFIX}${job.data.payload.authCheck.scope.projectId}/${clickhouseEntityType}/${job.data.payload.data.eventBodyId}/`,
|
||||
);
|
||||
recordDistribution(
|
||||
"langfuse.ingestion.count_files_distribution",
|
||||
eventFiles.length,
|
||||
{
|
||||
kind: clickhouseEntityType,
|
||||
},
|
||||
);
|
||||
span?.setAttribute(
|
||||
"langfuse.ingestion.event.count_files",
|
||||
eventFiles.length,
|
||||
);
|
||||
span?.setAttribute("langfuse.ingestion.event.kind", clickhouseEntityType);
|
||||
|
||||
const firstS3WriteTime =
|
||||
eventFiles
|
||||
|
||||
@@ -816,8 +816,16 @@ export class IngestionService {
|
||||
};
|
||||
}) {
|
||||
if (await this.shouldSkipClickHouseRead(params.projectId)) {
|
||||
recordIncrement("langfuse.ingestion.clickhouse_read_for_update", 1, {
|
||||
skipped: "true",
|
||||
table: params.table,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
recordIncrement("langfuse.ingestion.clickhouse_read_for_update", 1, {
|
||||
skipped: "false",
|
||||
table: params.table,
|
||||
});
|
||||
|
||||
const recordParser = {
|
||||
traces: traceRecordReadSchema,
|
||||
|
||||
Reference in New Issue
Block a user