Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
879ef9c8f9 | ||
|
|
a343d5b187 | ||
|
|
ac2b9eaa56 | ||
|
|
43e9cba99a | ||
|
|
601f431f69 | ||
|
|
4fd1f86f9d | ||
|
|
8245888e3c | ||
|
|
daf4e2fe02 | ||
|
|
129693fb69 | ||
|
|
204948db44 | ||
|
|
d42ba5fc99 | ||
|
|
97a539ced3 | ||
|
|
9d8dace197 | ||
|
|
bc2dc4d89c | ||
|
|
2dd5c8a8aa | ||
|
|
d17b21f04d | ||
|
|
c5a0f44bdd | ||
|
|
011b4912f2 | ||
|
|
a4d773066f | ||
|
|
25bdb1690b | ||
|
|
f8565d7772 | ||
|
|
a912057082 | ||
|
|
e0d3270f50 | ||
|
|
99e5a0b224 | ||
|
|
bccdee5410 | ||
|
|
738cbbc8cd | ||
|
|
920c52bb06 | ||
|
|
4f134790af | ||
|
|
21b3ce3c82 | ||
|
|
0531b57e1a | ||
|
|
7b857a0dc4 | ||
|
|
6c97fe3c04 | ||
|
|
1d69cbd41b | ||
|
|
ab26692913 | ||
|
|
b791544864 | ||
|
|
b35583056b | ||
|
|
4504530e3d | ||
|
|
45eed4b5c0 | ||
|
|
9fa31ba68e | ||
|
|
ea35c25269 |
@@ -22,6 +22,13 @@ service:
|
||||
docs: limit of items per page
|
||||
response: PaginatedAnnotationQueues
|
||||
|
||||
createQueue:
|
||||
docs: Create an annotation queue
|
||||
method: POST
|
||||
path: /annotation-queues
|
||||
request: CreateAnnotationQueueRequest
|
||||
response: AnnotationQueue
|
||||
|
||||
getQueue:
|
||||
docs: Get an annotation queue by ID
|
||||
method: GET
|
||||
@@ -168,6 +175,12 @@ types:
|
||||
data: list<AnnotationQueueItem>
|
||||
meta: pagination.MetaResponse
|
||||
|
||||
CreateAnnotationQueueRequest:
|
||||
properties:
|
||||
name: string
|
||||
description: optional<string>
|
||||
scoreConfigIds: list<string>
|
||||
|
||||
CreateAnnotationQueueItemRequest:
|
||||
properties:
|
||||
objectId: string
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse",
|
||||
"version": "3.96.2",
|
||||
"version": "3.97.4",
|
||||
"author": "engineering@langfuse.com",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
|
||||
@@ -158,6 +158,12 @@ const EnvSchema = z.object({
|
||||
LANGFUSE_EXPERIMENT_INSERT_INTO_AGGREGATING_MERGE_TREES: z
|
||||
.enum(["true", "false"])
|
||||
.default("false"),
|
||||
LANGFUSE_EXPERIMENT_WHITELISTED_AMT_TABLES: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((s) =>
|
||||
s ? s.split(",").map((s) => s.toLowerCase().trim()) : [],
|
||||
),
|
||||
LANGFUSE_INGESTION_PROCESSING_SAMPLED_PROJECTS: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -190,10 +196,10 @@ const EnvSchema = z.object({
|
||||
return new Map<string, number>();
|
||||
}
|
||||
}),
|
||||
|
||||
SLACK_CLIENT_ID: z.string().optional(),
|
||||
SLACK_CLIENT_SECRET: z.string().optional(),
|
||||
SLACK_STATE_SECRET: z.string().optional(),
|
||||
HTTPS_PROXY: z.string().optional(),
|
||||
|
||||
LANGFUSE_SERVER_SIDE_IO_CHAR_LIMIT: z.coerce
|
||||
.number()
|
||||
|
||||
@@ -27,16 +27,42 @@ export const parseUnknownToString = (value: unknown): string => {
|
||||
return String(value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Recursively parses JSON strings that may have been encoded multiple times.
|
||||
* This handles cases where data has been JSON.stringify'd multiple times.
|
||||
*
|
||||
* @param value - The potentially multi-encoded JSON string
|
||||
* @returns The final parsed object or the original value if parsing fails
|
||||
*/
|
||||
function parseMultiEncodedJson(value: unknown): unknown {
|
||||
if (typeof value !== "string") {
|
||||
return value;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
|
||||
// If result is still a string, it might be double-encoded - recurse
|
||||
if (typeof parsed === "string") {
|
||||
return parseMultiEncodedJson(parsed);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
} catch {
|
||||
// If parsing fails, return original value
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonDefault(selectedColumn: unknown, jsonSelector: string) {
|
||||
// selectedColumn should already be preprocessed by preprocessObjectWithJsonFields
|
||||
// so we can directly use it with JSONPath
|
||||
const result = JSONPath({
|
||||
path: jsonSelector,
|
||||
json:
|
||||
typeof selectedColumn === "string"
|
||||
? JSON.parse(selectedColumn)
|
||||
: selectedColumn,
|
||||
json: selectedColumn as any, // JSONPath accepts unknown but types are strict
|
||||
});
|
||||
|
||||
return result.length > 0 ? result[0] : undefined;
|
||||
return Array.isArray(result) && result.length > 0 ? result[0] : undefined;
|
||||
}
|
||||
|
||||
export function extractValueFromObject(
|
||||
@@ -44,7 +70,13 @@ export function extractValueFromObject(
|
||||
mapping: z.infer<typeof variableMapping>,
|
||||
parseJson?: (selectedColumn: unknown, jsonSelector: string) => unknown, // eslint-disable-line no-unused-vars
|
||||
): { value: string; error: Error | null } {
|
||||
const selectedColumn = obj[mapping.selectedColumnId];
|
||||
let selectedColumn = obj[mapping.selectedColumnId];
|
||||
|
||||
// Simple preprocessing: attempt to parse to valid JSON object
|
||||
if (typeof selectedColumn === "string") {
|
||||
selectedColumn = parseMultiEncodedJson(selectedColumn);
|
||||
}
|
||||
|
||||
const jsonParser = parseJson || parseJsonDefault;
|
||||
|
||||
let jsonSelectedColumn;
|
||||
|
||||
@@ -88,7 +88,7 @@ async function removeIngestionEventsFromS3AndDeleteClickhouseRefs(p: {
|
||||
);
|
||||
await softDeleteInClickhouse(blobStorageRefs);
|
||||
logger.info(
|
||||
`Deleted batch ${batch} of size ${blobStorageRefs.length} for ${projectId} of deleting s3 refs`,
|
||||
`Deleted last batch ${batch} of size ${blobStorageRefs.length} for ${projectId} of deleting s3 refs`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { env } from "../../env";
|
||||
import { logger } from "../../server/logger";
|
||||
import {
|
||||
DatasetRunItemsExecutionStrategy,
|
||||
DatasetRunItemsOperationType,
|
||||
@@ -48,23 +47,8 @@ export async function executeWithDatasetRunItemsStrategy<TInput, TOutput>({
|
||||
if (operationType === DatasetRunItemsOperationType.WRITE) {
|
||||
// For write operations, implement dual-write strategy
|
||||
if (strategy.shouldWriteToClickHouse) {
|
||||
// Dual-write phase: write to both databases
|
||||
const postgresResult = await postgresExecution(input);
|
||||
|
||||
try {
|
||||
await clickhouseExecution(input);
|
||||
logger.debug("Successfully wrote to both PostgreSQL and ClickHouse", {
|
||||
operation: `dataset_run_items_${operationType}`,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("ClickHouse write failed during dual-write phase", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
operation: `dataset_run_items_${operationType}`,
|
||||
});
|
||||
// Continue with PostgreSQL result since it succeeded
|
||||
}
|
||||
|
||||
return postgresResult;
|
||||
// Write only to ClickHouse
|
||||
return await clickhouseExecution(input);
|
||||
} else {
|
||||
// Write only to PostgreSQL
|
||||
return await postgresExecution(input);
|
||||
@@ -74,20 +58,10 @@ export async function executeWithDatasetRunItemsStrategy<TInput, TOutput>({
|
||||
const shouldExecuteClickhouse = strategy.shouldReadFromClickHouse;
|
||||
|
||||
if (shouldExecuteClickhouse) {
|
||||
try {
|
||||
return await clickhouseExecution(input);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"ClickHouse execution failed, falling back to PostgreSQL",
|
||||
{
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
operation: `dataset_run_items_${operationType}`,
|
||||
},
|
||||
);
|
||||
// Fallback to PostgreSQL for reliability
|
||||
return await postgresExecution(input);
|
||||
}
|
||||
// Read from ClickHouse
|
||||
return await clickhouseExecution(input);
|
||||
} else {
|
||||
// Read from PostgreSQL
|
||||
return await postgresExecution(input);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export * from "./llm/fetchLLMCompletion";
|
||||
export * from "./llm/utils";
|
||||
export * from "./llm/types";
|
||||
export * from "./llm/compileChatMessages";
|
||||
export * from "./llm/testModelCall";
|
||||
export * from "./utils/DatabaseReadStream";
|
||||
export * from "./utils/transforms";
|
||||
export * from "./clickhouse/client";
|
||||
|
||||
@@ -16,6 +16,8 @@ export type ModelMatchProps = {
|
||||
model: string;
|
||||
};
|
||||
|
||||
const MODEL_MATCH_CACHE_LOCKED_KEY = "LOCK:model-match-clear";
|
||||
|
||||
export async function findModel(p: ModelMatchProps): Promise<Model | null> {
|
||||
return instrumentAsync(
|
||||
{
|
||||
@@ -78,6 +80,14 @@ const getModelFromRedis = async (
|
||||
}
|
||||
|
||||
try {
|
||||
if (await isModelMatchCacheLocked()) {
|
||||
logger.info(
|
||||
"Model match cache is locked. Skipping model lookup from Redis.",
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = getRedisModelKey(p);
|
||||
const redisModel = await redis?.get(key);
|
||||
if (redisModel) {
|
||||
@@ -241,6 +251,70 @@ export async function clearModelCacheForProject(
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error clearing model cache for project ${projectId}`, error);
|
||||
logger.error(
|
||||
`Error clearing model cache for project ${projectId}: ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function isModelMatchCacheLocked() {
|
||||
try {
|
||||
return Boolean(await redis?.exists(MODEL_MATCH_CACHE_LOCKED_KEY));
|
||||
} catch (err) {
|
||||
logger.error("Failed to check whether model match is locked", err);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearFullModelCache() {
|
||||
if (env.LANGFUSE_CACHE_MODEL_MATCH_ENABLED === "false" || !redis) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Use lock to protect for concurrent executions
|
||||
// This function is called on worker startup, so we want to avoid all workers triggering this delete
|
||||
if (await isModelMatchCacheLocked()) {
|
||||
logger.info("Model cache clearing already in progress; skipping.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
logger.info("Clearing full model cache...");
|
||||
|
||||
const tenMinutesInSeconds = 60 * 10;
|
||||
await redis.setex(
|
||||
MODEL_MATCH_CACHE_LOCKED_KEY,
|
||||
tenMinutesInSeconds,
|
||||
"locked",
|
||||
);
|
||||
|
||||
const pattern = getModelMatchKeyPrefix() + "*";
|
||||
|
||||
const keys =
|
||||
env.REDIS_CLUSTER_ENABLED === "true"
|
||||
? (
|
||||
await Promise.all(
|
||||
(redis as Cluster)
|
||||
.nodes("master")
|
||||
.map((node) => node.keys(pattern) || []),
|
||||
)
|
||||
).flat()
|
||||
: await redis.keys(pattern);
|
||||
|
||||
if (keys.length > 0) {
|
||||
await safeMultiDel(redis, keys);
|
||||
logger.info(
|
||||
`Cleared full model cache with ${keys.length} keys in ${Date.now() - startTime}ms.`,
|
||||
);
|
||||
} else {
|
||||
logger.info(`No keys found for match pattern '${pattern}'`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error clearing full model cache: ${error}`);
|
||||
} finally {
|
||||
await redis?.del(MODEL_MATCH_CACHE_LOCKED_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
} from "./types";
|
||||
import { CallbackHandler } from "langfuse-langchain";
|
||||
import type { BaseCallbackHandler } from "@langchain/core/callbacks/base";
|
||||
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||
|
||||
const isLangfuseCloud = Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION);
|
||||
|
||||
@@ -217,6 +218,10 @@ export async function fetchLLMCompletion(
|
||||
(m) => m.content.length > 0 || "tool_calls" in m,
|
||||
);
|
||||
|
||||
// Common proxy configuration for all adapters
|
||||
const proxyUrl = env.HTTPS_PROXY;
|
||||
const proxyAgent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined;
|
||||
|
||||
let chatModel:
|
||||
| ChatOpenAI
|
||||
| ChatAnthropic
|
||||
@@ -232,7 +237,11 @@ export async function fetchLLMCompletion(
|
||||
maxTokens: modelParams.max_tokens,
|
||||
topP: modelParams.top_p,
|
||||
callbacks: finalCallbacks,
|
||||
clientOptions: { maxRetries, timeout: 1000 * 60 * 2 }, // 2 minutes timeout
|
||||
clientOptions: {
|
||||
maxRetries,
|
||||
timeout: 1000 * 60 * 2, // 2 minutes timeout
|
||||
...(proxyAgent && { httpAgent: proxyAgent }),
|
||||
},
|
||||
});
|
||||
} else if (modelParams.adapter === LLMAdapter.OpenAI) {
|
||||
chatModel = new ChatOpenAI({
|
||||
@@ -247,6 +256,7 @@ export async function fetchLLMCompletion(
|
||||
configuration: {
|
||||
baseURL,
|
||||
defaultHeaders: extraHeaders,
|
||||
...(proxyAgent && { httpAgent: proxyAgent }),
|
||||
},
|
||||
timeout: 1000 * 60 * 2, // 2 minutes timeout
|
||||
});
|
||||
@@ -264,6 +274,7 @@ export async function fetchLLMCompletion(
|
||||
timeout: 1000 * 60 * 2, // 2 minutes timeout
|
||||
configuration: {
|
||||
defaultHeaders: extraHeaders,
|
||||
...(proxyAgent && { httpAgent: proxyAgent }),
|
||||
},
|
||||
});
|
||||
} else if (modelParams.adapter === LLMAdapter.Bedrock) {
|
||||
@@ -376,6 +387,7 @@ export async function fetchLLMCompletion(
|
||||
},
|
||||
configuration: {
|
||||
baseURL,
|
||||
...(proxyAgent && { httpAgent: proxyAgent }),
|
||||
},
|
||||
timeout: 1000 * 60 * 2, // 2 minutes timeout
|
||||
})
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { z as zodV3 } from "zod/v3";
|
||||
import {
|
||||
ChatMessageRole,
|
||||
ChatMessageType,
|
||||
LLMApiKeySchema,
|
||||
type ModelConfig,
|
||||
} from "./types";
|
||||
import { decrypt } from "../../encryption";
|
||||
import { fetchLLMCompletion } from "./fetchLLMCompletion";
|
||||
import { decryptAndParseExtraHeaders } from "./utils";
|
||||
import z from "zod/v4";
|
||||
|
||||
export const testModelCall = async ({
|
||||
provider,
|
||||
model,
|
||||
apiKey,
|
||||
prompt,
|
||||
modelConfig,
|
||||
}: {
|
||||
provider: string;
|
||||
model: string;
|
||||
apiKey: z.infer<typeof LLMApiKeySchema>;
|
||||
prompt?: string;
|
||||
modelConfig?: ModelConfig | null;
|
||||
}) => {
|
||||
(
|
||||
await fetchLLMCompletion({
|
||||
streaming: false,
|
||||
apiKey: decrypt(apiKey.secretKey), // decrypt the secret key
|
||||
extraHeaders: decryptAndParseExtraHeaders(apiKey.extraHeaders),
|
||||
baseURL: apiKey.baseURL ?? undefined,
|
||||
messages: [
|
||||
{
|
||||
role: ChatMessageRole.User,
|
||||
content: prompt ?? "mock content",
|
||||
type: ChatMessageType.User,
|
||||
},
|
||||
],
|
||||
modelParams: {
|
||||
provider: provider,
|
||||
model: model,
|
||||
adapter: apiKey.adapter,
|
||||
...modelConfig,
|
||||
},
|
||||
structuredOutputSchema: zodV3.object({
|
||||
score: zodV3.string(),
|
||||
reasoning: zodV3.string(),
|
||||
}),
|
||||
config: apiKey.config,
|
||||
})
|
||||
).completion;
|
||||
};
|
||||
@@ -294,6 +294,12 @@ export const openAIModels = [
|
||||
"gpt-4.1-mini-2025-04-14",
|
||||
"gpt-4.1-nano",
|
||||
"gpt-4.1-nano-2025-04-14",
|
||||
"gpt-5",
|
||||
"gpt-5-2025-08-07",
|
||||
"gpt-5-mini",
|
||||
"gpt-5-mini-2025-08-07",
|
||||
"gpt-5-nano",
|
||||
"gpt-5-nano-2025-08-07",
|
||||
"o3",
|
||||
"o3-2025-04-16",
|
||||
"o4-mini",
|
||||
|
||||
@@ -1508,6 +1508,15 @@ export const getGenerationsForPostHog = async function* (
|
||||
minTimestamp: Date,
|
||||
maxTimestamp: Date,
|
||||
) {
|
||||
// Determine which trace table to use based on experiment flag
|
||||
const useAMT = env.LANGFUSE_EXPERIMENT_RETURN_NEW_RESULT === "true";
|
||||
// Subtract 7d from minTimestamp to account for shift in query
|
||||
const traceTable = useAMT
|
||||
? getTimeframesTracesAMT(
|
||||
new Date(minTimestamp.getTime() - 7 * 24 * 60 * 60 * 1000),
|
||||
)
|
||||
: "traces";
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
o.name as name,
|
||||
@@ -1532,7 +1541,7 @@ export const getGenerationsForPostHog = async function* (
|
||||
t.tags as trace_tags,
|
||||
t.metadata['$posthog_session_id'] as posthog_session_id
|
||||
FROM observations o FINAL
|
||||
LEFT JOIN traces t FINAL ON o.trace_id = t.id AND o.project_id = t.project_id
|
||||
LEFT JOIN ${traceTable} t FINAL ON o.trace_id = t.id AND o.project_id = t.project_id
|
||||
WHERE o.project_id = {projectId: String}
|
||||
AND t.project_id = {projectId: String}
|
||||
AND o.start_time >= {minTimestamp: DateTime64(3)}
|
||||
@@ -1554,6 +1563,7 @@ export const getGenerationsForPostHog = async function* (
|
||||
type: "observation",
|
||||
kind: "analytic",
|
||||
projectId,
|
||||
experiment_amt: useAMT ? "new" : "original",
|
||||
},
|
||||
clickhouseConfigs: {
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DATA_EXPORT_REQUEST_TIMEOUT_MS,
|
||||
|
||||
@@ -33,6 +33,7 @@ import { ClickHouseClientConfigOptions } from "@clickhouse/client";
|
||||
import { recordDistribution } from "../instrumentation";
|
||||
import { prisma } from "../../db";
|
||||
import { measureAndReturn } from "../clickhouse/measureAndReturn";
|
||||
import { getTimeframesTracesAMT } from "./traces";
|
||||
|
||||
export const searchExistingAnnotationScore = async (
|
||||
projectId: string,
|
||||
@@ -1152,7 +1153,7 @@ export const getNumericScoreHistogram = async (
|
||||
const query = `
|
||||
select s.value
|
||||
from scores s
|
||||
${traceFilter ? `LEFT JOIN traces t ON s.trace_id = t.id AND t.project_id = s.project_id` : ""}
|
||||
${traceFilter ? `LEFT JOIN __TRACE_TABLE__ t ON s.trace_id = t.id AND t.project_id = s.project_id` : ""}
|
||||
WHERE s.project_id = {projectId: String}
|
||||
${traceFilter ? `AND t.project_id = {projectId: String}` : ""}
|
||||
${chFilterRes?.query ? `AND ${chFilterRes.query}` : ""}
|
||||
@@ -1161,18 +1162,45 @@ export const getNumericScoreHistogram = async (
|
||||
${limit !== undefined ? `limit {limit: Int32}` : ""}
|
||||
`;
|
||||
|
||||
return queryClickhouse<{ value: number }>({
|
||||
query,
|
||||
params: {
|
||||
projectId,
|
||||
limit,
|
||||
...(chFilterRes ? chFilterRes.params : {}),
|
||||
// Extract timestamp from filter for AMT table selection
|
||||
const timestampFilter = chFilter.find(
|
||||
(f) => f.clickhouseTable === "traces" && f.field === "timestamp",
|
||||
) as TimeFilter | undefined;
|
||||
const timestamp = timestampFilter?.value;
|
||||
|
||||
return measureAndReturn({
|
||||
operationName: "getNumericScoreHistogram",
|
||||
projectId,
|
||||
minStartTime: timestamp,
|
||||
input: {
|
||||
params: {
|
||||
projectId,
|
||||
limit,
|
||||
...(chFilterRes ? chFilterRes.params : {}),
|
||||
},
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
type: "score",
|
||||
kind: "analytic",
|
||||
projectId,
|
||||
operation_name: "getNumericScoreHistogram",
|
||||
},
|
||||
timestamp,
|
||||
},
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
type: "score",
|
||||
kind: "analytic",
|
||||
projectId,
|
||||
existingExecution: async (input) => {
|
||||
return queryClickhouse<{ value: number }>({
|
||||
query: query.replace("__TRACE_TABLE__", "traces"),
|
||||
params: input.params,
|
||||
tags: { ...input.tags, experiment_amt: "original" },
|
||||
});
|
||||
},
|
||||
newExecution: async (input) => {
|
||||
const traceAmt = getTimeframesTracesAMT(input.timestamp);
|
||||
return queryClickhouse<{ value: number }>({
|
||||
query: query.replace("__TRACE_TABLE__", traceAmt),
|
||||
params: input.params,
|
||||
tags: { ...input.tags, experiment_amt: "new" },
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1399,6 +1427,15 @@ export const getScoresForPostHog = async function* (
|
||||
minTimestamp: Date,
|
||||
maxTimestamp: Date,
|
||||
) {
|
||||
// Determine which trace table to use based on experiment flag
|
||||
const useAMT = env.LANGFUSE_EXPERIMENT_RETURN_NEW_RESULT === "true";
|
||||
// Subtract 7d from minTimestamp to account for shift in query
|
||||
const traceTable = useAMT
|
||||
? getTimeframesTracesAMT(
|
||||
new Date(minTimestamp.getTime() - 7 * 24 * 60 * 60 * 1000),
|
||||
)
|
||||
: "traces";
|
||||
|
||||
const query = ` SELECT
|
||||
s.id as id,
|
||||
s.timestamp as timestamp,
|
||||
@@ -1417,7 +1454,7 @@ export const getScoresForPostHog = async function* (
|
||||
s.metadata as metadata,
|
||||
t.metadata['$posthog_session_id'] as posthog_session_id
|
||||
FROM scores s FINAL
|
||||
LEFT JOIN traces t FINAL ON s.trace_id = t.id AND s.project_id = t.project_id
|
||||
LEFT JOIN ${traceTable} t FINAL ON s.trace_id = t.id AND s.project_id = t.project_id
|
||||
WHERE s.project_id = {projectId: String}
|
||||
AND t.project_id = {projectId: String}
|
||||
AND s.timestamp >= {minTimestamp: DateTime64(3)}
|
||||
@@ -1438,6 +1475,7 @@ export const getScoresForPostHog = async function* (
|
||||
type: "score",
|
||||
kind: "analytic",
|
||||
projectId,
|
||||
experiment_amt: useAMT ? "new" : "original",
|
||||
},
|
||||
clickhouseConfigs: {
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DATA_EXPORT_REQUEST_TIMEOUT_MS,
|
||||
|
||||
@@ -47,12 +47,16 @@ enum TracesAMTs {
|
||||
* for <= 29 days, we use traces_30d_amt,
|
||||
* for all other cases we use traces_all_amt.
|
||||
*
|
||||
* If LANGFUSE_EXPERIMENT_WHITELISTED_AMT_TABLES is set, we only return timeframes
|
||||
* that are whitelisted or fallback to the traces_all_amt.
|
||||
*
|
||||
* @param fromTimestamp
|
||||
*/
|
||||
export const getTimeframesTracesAMT = (
|
||||
fromTimestamp: Date | undefined,
|
||||
): TracesAMTs => {
|
||||
if (!fromTimestamp) {
|
||||
// The TracesAllAMT must always be returned if there is no timestamp.
|
||||
return TracesAMTs.TracesAllAMT;
|
||||
}
|
||||
|
||||
@@ -60,12 +64,21 @@ export const getTimeframesTracesAMT = (
|
||||
const diffInDays = Math.floor(
|
||||
(now.getTime() - fromTimestamp.getTime()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
|
||||
let selectedTable: TracesAMTs;
|
||||
if (diffInDays <= 6) {
|
||||
return TracesAMTs.Traces7dAMT;
|
||||
selectedTable = TracesAMTs.Traces7dAMT;
|
||||
} else if (diffInDays <= 29) {
|
||||
return TracesAMTs.Traces30dAMT;
|
||||
selectedTable = TracesAMTs.Traces30dAMT;
|
||||
} else {
|
||||
selectedTable = TracesAMTs.TracesAllAMT;
|
||||
}
|
||||
return TracesAMTs.TracesAllAMT;
|
||||
|
||||
// Check if the selected table is whitelisted, fallback to TracesAllAMT if not
|
||||
return env.LANGFUSE_EXPERIMENT_WHITELISTED_AMT_TABLES.length === 0 ||
|
||||
env.LANGFUSE_EXPERIMENT_WHITELISTED_AMT_TABLES.includes(selectedTable)
|
||||
? selectedTable
|
||||
: TracesAMTs.TracesAllAMT;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1736,6 +1749,10 @@ export const getTracesForBlobStorageExport = function (
|
||||
minTimestamp: Date,
|
||||
maxTimestamp: Date,
|
||||
) {
|
||||
// Determine which trace table to use based on experiment flag
|
||||
const useAMT = env.LANGFUSE_EXPERIMENT_RETURN_NEW_RESULT === "true";
|
||||
const traceTable = useAMT ? getTimeframesTracesAMT(minTimestamp) : "traces";
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
id,
|
||||
@@ -1748,12 +1765,12 @@ export const getTracesForBlobStorageExport = function (
|
||||
session_id,
|
||||
release,
|
||||
version,
|
||||
public,
|
||||
bookmarked,
|
||||
${useAMT ? "finalizeAggregation(public)" : "public"} as public,
|
||||
${useAMT ? "finalizeAggregation(bookmarked)" : "bookmarked"} as bookmarked,
|
||||
tags,
|
||||
input,
|
||||
output
|
||||
FROM traces FINAL
|
||||
${useAMT ? "finalizeAggregation(input)" : "input"} as input,
|
||||
${useAMT ? "finalizeAggregation(output)" : "output"} as output
|
||||
FROM ${traceTable} FINAL
|
||||
WHERE project_id = {projectId: String}
|
||||
AND timestamp >= {minTimestamp: DateTime64(3)}
|
||||
AND timestamp <= {maxTimestamp: DateTime64(3)}
|
||||
@@ -1771,6 +1788,7 @@ export const getTracesForBlobStorageExport = function (
|
||||
type: "trace",
|
||||
kind: "analytic",
|
||||
projectId,
|
||||
experiment_amt: useAMT ? "new" : "original",
|
||||
},
|
||||
clickhouseConfigs: {
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DATA_EXPORT_REQUEST_TIMEOUT_MS,
|
||||
@@ -1783,6 +1801,10 @@ export const getTracesForPostHog = async function* (
|
||||
minTimestamp: Date,
|
||||
maxTimestamp: Date,
|
||||
) {
|
||||
// Determine which trace table to use based on experiment flag
|
||||
const useAMT = env.LANGFUSE_EXPERIMENT_RETURN_NEW_RESULT === "true";
|
||||
const traceTable = useAMT ? getTimeframesTracesAMT(minTimestamp) : "traces";
|
||||
|
||||
const query = `
|
||||
WITH observations_agg AS (
|
||||
SELECT o.project_id,
|
||||
@@ -1810,7 +1832,7 @@ export const getTracesForPostHog = async function* (
|
||||
o.total_cost as total_cost,
|
||||
o.latency_milliseconds / 1000 as latency,
|
||||
o.observation_count as observation_count
|
||||
FROM traces t FINAL
|
||||
FROM ${traceTable} t FINAL
|
||||
LEFT JOIN observations_agg o ON t.id = o.trace_id AND t.project_id = o.project_id
|
||||
WHERE t.project_id = {projectId: String}
|
||||
AND t.timestamp >= {minTimestamp: DateTime64(3)}
|
||||
@@ -1829,6 +1851,7 @@ export const getTracesForPostHog = async function* (
|
||||
type: "trace",
|
||||
kind: "analytic",
|
||||
projectId,
|
||||
experiment_amt: useAMT ? "new" : "original",
|
||||
},
|
||||
clickhouseConfigs: {
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DATA_EXPORT_REQUEST_TIMEOUT_MS,
|
||||
|
||||
+23
-1
@@ -1,7 +1,12 @@
|
||||
import z from "zod/v4";
|
||||
import { prisma } from "../../../db";
|
||||
import { LangfuseNotFoundError, QUEUE_ERROR_MESSAGES } from "../../../errors";
|
||||
import {
|
||||
ForbiddenError,
|
||||
LangfuseNotFoundError,
|
||||
QUEUE_ERROR_MESSAGES,
|
||||
} from "../../../errors";
|
||||
import { LLMApiKeySchema, ZodModelConfig } from "../../llm/types";
|
||||
import { testModelCall } from "../../llm/testModelCall";
|
||||
|
||||
type ValidConfig = {
|
||||
provider: string;
|
||||
@@ -47,6 +52,23 @@ export class DefaultEvalModelService {
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if (LLMApiKeySchema.safeParse(llmApiKey).success) {
|
||||
// Make a test structured output call to validate the LLM key
|
||||
await testModelCall({
|
||||
provider,
|
||||
model,
|
||||
apiKey: llmApiKey as z.infer<typeof LLMApiKeySchema>,
|
||||
modelConfig: modelParams,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
throw new ForbiddenError(
|
||||
`Model configuration not valid for evaluation. ${message}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Create or update the default model
|
||||
return prisma.defaultLlmModel.upsert({
|
||||
where: {
|
||||
|
||||
@@ -15,6 +15,7 @@ export function createBasicAuthHeader(
|
||||
|
||||
export type CreateOrgProjectAndApiKeyOptions = {
|
||||
projectId?: string;
|
||||
plan?: "Team" | "Hobby" | "Core" | "Pro" | "Enterprise";
|
||||
};
|
||||
export const createOrgProjectAndApiKey = async (
|
||||
props?: CreateOrgProjectAndApiKeyOptions,
|
||||
@@ -25,7 +26,7 @@ export const createOrgProjectAndApiKey = async (
|
||||
id: v4(),
|
||||
name: v4(),
|
||||
cloudConfig: CloudConfigSchema.parse({
|
||||
plan: "Team",
|
||||
plan: props?.plan ?? "Team",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "3.96.2",
|
||||
"version": "3.97.4",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -79,6 +79,52 @@ paths:
|
||||
schema: {}
|
||||
security:
|
||||
- BasicAuth: []
|
||||
post:
|
||||
description: Create an annotation queue
|
||||
operationId: annotationQueues_createQueue
|
||||
tags:
|
||||
- AnnotationQueues
|
||||
parameters: []
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AnnotationQueue'
|
||||
'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:
|
||||
$ref: '#/components/schemas/CreateAnnotationQueueRequest'
|
||||
/api/public/annotation-queues/{queueId}:
|
||||
get:
|
||||
description: Get an annotation queue by ID
|
||||
@@ -3254,7 +3300,7 @@ paths:
|
||||
parameters:
|
||||
- name: filter
|
||||
in: query
|
||||
description: Filter expression (e.g. userName eq 'value')
|
||||
description: Filter expression (e.g. userName eq "value")
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
@@ -4459,6 +4505,22 @@ components:
|
||||
required:
|
||||
- data
|
||||
- meta
|
||||
CreateAnnotationQueueRequest:
|
||||
title: CreateAnnotationQueueRequest
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
nullable: true
|
||||
scoreConfigIds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- scoreConfigIds
|
||||
CreateAnnotationQueueItemRequest:
|
||||
title: CreateAnnotationQueueItemRequest
|
||||
type: object
|
||||
|
||||
@@ -78,6 +78,39 @@
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"_type": "endpoint",
|
||||
"name": "Create Queue",
|
||||
"request": {
|
||||
"description": "Create an annotation queue",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/annotation-queues",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"public",
|
||||
"annotation-queues"
|
||||
],
|
||||
"query": [],
|
||||
"variable": []
|
||||
},
|
||||
"header": [],
|
||||
"method": "POST",
|
||||
"auth": null,
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"name\": \"example\",\n \"description\": \"example\",\n \"scoreConfigIds\": [\n \"example\"\n ]\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"_type": "endpoint",
|
||||
"name": "Get Queue",
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
CreateAnnotationQueueItemResponse,
|
||||
UpdateAnnotationQueueItemResponse,
|
||||
DeleteAnnotationQueueItemResponse,
|
||||
CreateAnnotationQueueResponse,
|
||||
} from "@/src/features/public-api/types/annotation-queues";
|
||||
import {
|
||||
AnnotationQueueObjectType,
|
||||
@@ -173,6 +174,120 @@ describe("Annotation Queues API Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /annotation-queues", () => {
|
||||
it("should create a new annotation queue", async () => {
|
||||
const scoreConfig = await prisma.scoreConfig.create({
|
||||
data: {
|
||||
name: "Test Score Config",
|
||||
description: "Test Score Config Description",
|
||||
projectId,
|
||||
dataType: "NUMERIC",
|
||||
},
|
||||
});
|
||||
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
CreateAnnotationQueueResponse,
|
||||
"POST",
|
||||
"/api/public/annotation-queues",
|
||||
{
|
||||
name: "Test Queue",
|
||||
description: "Test Queue Description",
|
||||
scoreConfigIds: [scoreConfig.id],
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.id).toBeDefined();
|
||||
expect(response.body.name).toBe("Test Queue");
|
||||
expect(response.body.description).toBe("Test Queue Description");
|
||||
expect(response.body.scoreConfigIds).toEqual([scoreConfig.id]);
|
||||
});
|
||||
|
||||
it("should return 400 if the queue name already exists", async () => {
|
||||
const response = await makeAPICall(
|
||||
"POST",
|
||||
"/api/public/annotation-queues",
|
||||
{
|
||||
name: "Test Queue",
|
||||
description: "Test Queue Description",
|
||||
scoreConfigIds: [],
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("should return 400 if no score config IDs are provided", async () => {
|
||||
const response = await makeAPICall(
|
||||
"POST",
|
||||
"/api/public/annotation-queues",
|
||||
{
|
||||
name: "No configs queue",
|
||||
description: "Test Queue Description",
|
||||
scoreConfigIds: [],
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("should return 400 if the score config IDs are invalid", async () => {
|
||||
const response = await makeAPICall(
|
||||
"POST",
|
||||
"/api/public/annotation-queues",
|
||||
{
|
||||
name: "Invalid configs queue",
|
||||
description: "Test Queue Description",
|
||||
scoreConfigIds: ["invalid-score-config-id"],
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("should return 405 if the user is on the Hobby plan and has reached the maximum number of annotation queues", async () => {
|
||||
const { auth: hobbyPlanAuth, projectId: hobbyProjectId } =
|
||||
await createOrgProjectAndApiKey({
|
||||
plan: "Hobby",
|
||||
});
|
||||
|
||||
const config = await prisma.scoreConfig.create({
|
||||
data: {
|
||||
name: "Test Score Config",
|
||||
description: "Test Score Config Description",
|
||||
projectId: hobbyProjectId,
|
||||
dataType: "NUMERIC",
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.annotationQueue.create({
|
||||
data: {
|
||||
name: "First queue",
|
||||
description: "First queue description",
|
||||
scoreConfigIds: [config.id],
|
||||
projectId: hobbyProjectId,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await makeAPICall(
|
||||
"POST",
|
||||
"/api/public/annotation-queues",
|
||||
{
|
||||
name: "Hobby plan queue",
|
||||
description: "Test Queue Description",
|
||||
scoreConfigIds: [config.id],
|
||||
},
|
||||
hobbyPlanAuth,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(405);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /annotation-queues/:queueId", () => {
|
||||
it("should get a specific annotation queue", async () => {
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
|
||||
@@ -497,4 +497,137 @@ describe("/api/public/metrics API Endpoint", () => {
|
||||
expect(height).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LFE-6148: Comprehensive filter validation", () => {
|
||||
it("should return 400 error for invalid array field filters", async () => {
|
||||
// Test using string type on array field (tags) - should return validation error
|
||||
const invalidStringTypeQuery = {
|
||||
view: "traces",
|
||||
dimensions: [{ field: "name" }],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [
|
||||
{
|
||||
column: "tags",
|
||||
operator: "contains",
|
||||
value: "test-tag",
|
||||
type: "string", // Invalid: array fields require arrayOptions type
|
||||
},
|
||||
],
|
||||
timeDimension: {
|
||||
granularity: "day",
|
||||
},
|
||||
fromTimestamp: yesterday.toISOString(),
|
||||
toTimestamp: tomorrow.toISOString(),
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
// Make API call and expect 400 error
|
||||
const response = await makeAPICall(
|
||||
"GET",
|
||||
`/api/public/metrics?query=${encodeURIComponent(JSON.stringify(invalidStringTypeQuery))}`,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toMatchObject({
|
||||
error: "InvalidRequestError",
|
||||
message: expect.stringContaining(
|
||||
"Array fields require type 'arrayOptions', not 'string'",
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 400 error for invalid metadata filters", async () => {
|
||||
// Test using wrong type for metadata field
|
||||
const invalidMetadataTypeQuery = {
|
||||
view: "traces",
|
||||
dimensions: [{ field: "name" }],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [
|
||||
{
|
||||
column: "metadata",
|
||||
operator: "contains",
|
||||
value: "test-value",
|
||||
type: "string", // Invalid: metadata requires stringObject type
|
||||
},
|
||||
],
|
||||
timeDimension: null,
|
||||
fromTimestamp: yesterday.toISOString(),
|
||||
toTimestamp: tomorrow.toISOString(),
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
const response = await makeAPICall(
|
||||
"GET",
|
||||
`/api/public/metrics?query=${encodeURIComponent(JSON.stringify(invalidMetadataTypeQuery))}`,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toMatchObject({
|
||||
error: "InvalidRequestError",
|
||||
message: expect.stringContaining(
|
||||
"Metadata filters require type 'stringObject'",
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
it("should work correctly with proper array field filter configuration", async () => {
|
||||
// Setup test data with tags
|
||||
const taggedTraceId = randomUUID();
|
||||
await createTracesCh([
|
||||
createTrace({
|
||||
id: taggedTraceId,
|
||||
name: "tagged-trace",
|
||||
project_id: projectId,
|
||||
timestamp: now.getTime(),
|
||||
tags: ["test-tag", "another-tag"],
|
||||
metadata: { test: testMetadataValue },
|
||||
}),
|
||||
]);
|
||||
|
||||
// Test with correct arrayOptions filter
|
||||
const validQuery = {
|
||||
view: "traces",
|
||||
dimensions: [{ field: "name" }],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [
|
||||
{
|
||||
column: "tags",
|
||||
operator: "any of", // Correct operator for array fields
|
||||
value: ["test-tag"],
|
||||
type: "arrayOptions", // Correct type for array fields
|
||||
},
|
||||
{
|
||||
column: "metadata",
|
||||
operator: "contains",
|
||||
key: "test",
|
||||
value: testMetadataValue,
|
||||
type: "stringObject",
|
||||
},
|
||||
],
|
||||
timeDimension: null,
|
||||
fromTimestamp: yesterday.toISOString(),
|
||||
toTimestamp: tomorrow.toISOString(),
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
// Make the API call
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
GetMetricsV1Response,
|
||||
"GET",
|
||||
`/api/public/metrics?query=${encodeURIComponent(JSON.stringify(validQuery))}`,
|
||||
);
|
||||
|
||||
// Should succeed and return data
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(response.body.data)).toBe(true);
|
||||
expect(response.body.data.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify we got the tagged trace
|
||||
const taggedTraceResult = response.body.data.find(
|
||||
(row: any) => row.name === "tagged-trace",
|
||||
);
|
||||
expect(taggedTraceResult).toBeDefined();
|
||||
expect(taggedTraceResult.count_count).toBe("1");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const VERSION = "v3.96.2";
|
||||
export const VERSION = "v3.97.4";
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from "@/src/components/ui/button";
|
||||
import { MultiSelectCombobox } from "@/src/components/ui/multi-select-combobox";
|
||||
import { useUserSearch } from "@/src/features/annotation-queues/hooks/useUserSearch";
|
||||
import { useSelectedUsers } from "@/src/features/annotation-queues/hooks/useSelectedUsers";
|
||||
import { showSuccessToast } from "@/src/features/notifications/showSuccessToast";
|
||||
|
||||
interface UserAssignmentSectionProps {
|
||||
projectId: string;
|
||||
@@ -27,6 +28,7 @@ export const UserAssignmentSection = ({
|
||||
projectId: projectId,
|
||||
scope: "annotationQueueAssignments:CUD",
|
||||
});
|
||||
const utils = api.useUtils();
|
||||
|
||||
// Get current assigned users
|
||||
const queueAssignmentsQuery =
|
||||
@@ -35,6 +37,18 @@ export const UserAssignmentSection = ({
|
||||
{ enabled: !!queueId && hasQueueAssignmentsReadAccess },
|
||||
);
|
||||
|
||||
const deleteQueueAssignmentMutation =
|
||||
api.annotationQueueAssignments.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.annotationQueueAssignments.invalidate();
|
||||
utils.annotationQueues.invalidate();
|
||||
showSuccessToast({
|
||||
title: "Removed assignment",
|
||||
description: "User removed from queue successfully",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Combine selected users and assigned users for exclusion
|
||||
const assignedUserIds =
|
||||
queueAssignmentsQuery.data?.assignments.map((user: any) => user.id) || [];
|
||||
@@ -56,6 +70,16 @@ export const UserAssignmentSection = ({
|
||||
onChange(userIds);
|
||||
};
|
||||
|
||||
// Handle user removal
|
||||
const handleUserRemove = (userId: string) => {
|
||||
if (!!queueId)
|
||||
deleteQueueAssignmentMutation.mutate({
|
||||
projectId,
|
||||
queueId,
|
||||
userId,
|
||||
});
|
||||
};
|
||||
|
||||
// Check if there are more assigned users than shown
|
||||
const hasMoreAssignedUsers =
|
||||
queueAssignmentsQuery.data &&
|
||||
@@ -133,9 +157,17 @@ export const UserAssignmentSection = ({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* <Button variant="ghost" size="icon-sm">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={
|
||||
!hasQueueAssignmentWriteAccess ||
|
||||
deleteQueueAssignmentMutation.isLoading
|
||||
}
|
||||
onClick={() => handleUserRemove(user.id)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button> */}
|
||||
</Button>
|
||||
</div>
|
||||
{(index <
|
||||
queueAssignmentsQuery.data?.assignments.length - 1 ||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/src/components/ui/dialog";
|
||||
import { useState } from "react";
|
||||
import { useState, forwardRef } from "react";
|
||||
import { DialogTrigger } from "@radix-ui/react-dialog";
|
||||
import { DatasetForm } from "@/src/features/datasets/components/DatasetForm";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
@@ -46,7 +46,10 @@ type DatasetActionButtonProps =
|
||||
| UpdateDatasetButtonProps
|
||||
| DeleteDatasetButtonProps;
|
||||
|
||||
export const DatasetActionButton = (props: DatasetActionButtonProps) => {
|
||||
export const DatasetActionButton = forwardRef<
|
||||
HTMLButtonElement,
|
||||
DatasetActionButtonProps
|
||||
>((props, ref) => {
|
||||
const capture = usePostHogClientCapture();
|
||||
const [open, setOpen] = useState(false);
|
||||
const hasAccess = useHasProjectAccess({
|
||||
@@ -60,6 +63,7 @@ export const DatasetActionButton = (props: DatasetActionButtonProps) => {
|
||||
{props.mode === "update" ? (
|
||||
props.icon ? (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={props.variant || "outline"}
|
||||
size={props.size || "icon"}
|
||||
className={props.className}
|
||||
@@ -74,6 +78,7 @@ export const DatasetActionButton = (props: DatasetActionButtonProps) => {
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={props.variant || "ghost"}
|
||||
size={props.size || "icon"}
|
||||
className={props.className}
|
||||
@@ -95,6 +100,7 @@ export const DatasetActionButton = (props: DatasetActionButtonProps) => {
|
||||
)
|
||||
) : props.mode === "delete" ? (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={props.variant || "ghost"}
|
||||
size={props.size}
|
||||
className={props.className}
|
||||
@@ -115,6 +121,7 @@ export const DatasetActionButton = (props: DatasetActionButtonProps) => {
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
ref={ref}
|
||||
size={props.size}
|
||||
className={props.className}
|
||||
disabled={!hasAccess}
|
||||
@@ -175,4 +182,6 @@ export const DatasetActionButton = (props: DatasetActionButtonProps) => {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
DatasetActionButton.displayName = "DatasetActionButton";
|
||||
|
||||
@@ -2,6 +2,7 @@ import { DataTable } from "@/src/components/table/data-table";
|
||||
import TableLink from "@/src/components/table/table-link";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { type RouterOutput } from "@/src/utils/types";
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/src/components/ui/dropdown-menu";
|
||||
import { useQueryParams, withDefault, NumberParam } from "use-query-params";
|
||||
import { Archive, ListTree, MoreVertical, Trash2 } from "lucide-react";
|
||||
import { Archive, Edit, ListTree, MoreVertical, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { type DatasetItem, DatasetStatus, type Prisma } from "@langfuse/shared";
|
||||
import { type LangfuseColumnDef } from "@/src/components/table/types";
|
||||
@@ -53,6 +54,7 @@ export function DatasetItemsTable({
|
||||
datasetId: string;
|
||||
menuItems?: React.ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { setDetailPageList } = useDetailPageLists();
|
||||
const utils = api.useUtils();
|
||||
const capture = usePostHogClientCapture();
|
||||
@@ -229,6 +231,17 @@ export function DatasetItemsTable({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
disabled={!hasAccess}
|
||||
onClick={() => {
|
||||
router.push(
|
||||
`/project/${projectId}/datasets/${datasetId}/items/${id}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!hasAccess}
|
||||
onClick={() => {
|
||||
|
||||
@@ -214,6 +214,15 @@ export function parseColumns(
|
||||
headerMap: Map<string, number>,
|
||||
): Prisma.JsonValue {
|
||||
if (columnNames.length === 0) return null;
|
||||
|
||||
// Single column: do not nest columns into json objects
|
||||
if (columnNames.length === 1) {
|
||||
const col = columnNames[0];
|
||||
const rawValue = row[headerMap.get(col)!];
|
||||
return parseValue(rawValue);
|
||||
}
|
||||
|
||||
// Multiple columns: nest columns into json objects
|
||||
return Object.fromEntries(
|
||||
columnNames.map((col) => [col, parseValue(row[headerMap.get(col)!])]),
|
||||
);
|
||||
|
||||
@@ -33,9 +33,6 @@ export const EvalTemplateDetail = () => {
|
||||
const templateId = router.query.id as string;
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<EvalTemplate | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// get the current template by id
|
||||
const template = api.evals.templateById.useQuery({
|
||||
@@ -58,15 +55,7 @@ export const EvalTemplateDetail = () => {
|
||||
},
|
||||
);
|
||||
|
||||
// Set the selected template when data is loaded
|
||||
React.useEffect(() => {
|
||||
if (template.data && !selectedTemplate) {
|
||||
setSelectedTemplate(template.data);
|
||||
}
|
||||
}, [template.data, selectedTemplate]);
|
||||
|
||||
const handleTemplateSelect = (newTemplate: EvalTemplate) => {
|
||||
setSelectedTemplate(newTemplate);
|
||||
// Update URL without full page reload
|
||||
router.push(
|
||||
`/project/${projectId}/evals/templates/${newTemplate.id}`,
|
||||
@@ -75,13 +64,10 @@ export const EvalTemplateDetail = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// Get the appropriate template to display
|
||||
const displayTemplate = selectedTemplate || template.data;
|
||||
|
||||
return (
|
||||
<Page
|
||||
headerProps={{
|
||||
title: `${displayTemplate?.name || ""}`,
|
||||
title: `${template.data?.name ?? ""}`,
|
||||
itemType: "EVALUATOR",
|
||||
breadcrumb: [
|
||||
{
|
||||
@@ -95,7 +81,7 @@ export const EvalTemplateDetail = () => {
|
||||
projectId={projectId}
|
||||
isEditing={isEditing}
|
||||
setIsEditing={setIsEditing}
|
||||
isCustom={!!displayTemplate?.projectId}
|
||||
isCustom={!!template.data?.projectId}
|
||||
/>
|
||||
|
||||
{/* TODO: moved to LFE-4573 */}
|
||||
@@ -114,14 +100,14 @@ export const EvalTemplateDetail = () => {
|
||||
),
|
||||
}}
|
||||
>
|
||||
{allTemplates.isLoading || !allTemplates.data || !displayTemplate ? (
|
||||
{allTemplates.isLoading || !allTemplates.data || !template.data ? (
|
||||
<div className="p-3">Loading...</div>
|
||||
) : isEditing ? (
|
||||
<div className="overflow-y-auto p-3 pt-1">
|
||||
<EvalTemplateForm
|
||||
useDialog={false}
|
||||
projectId={projectId}
|
||||
existingEvalTemplate={displayTemplate}
|
||||
existingEvalTemplate={template.data}
|
||||
isEditing={isEditing}
|
||||
setIsEditing={setIsEditing}
|
||||
/>
|
||||
@@ -132,7 +118,7 @@ export const EvalTemplateDetail = () => {
|
||||
<EvalTemplateForm
|
||||
useDialog={false}
|
||||
projectId={projectId}
|
||||
existingEvalTemplate={displayTemplate}
|
||||
existingEvalTemplate={template.data}
|
||||
isEditing={isEditing}
|
||||
setIsEditing={setIsEditing}
|
||||
/>
|
||||
@@ -150,7 +136,7 @@ export const EvalTemplateDetail = () => {
|
||||
<div
|
||||
key={template.id}
|
||||
className={`flex cursor-pointer flex-col rounded-md px-2 py-1.5 hover:bg-accent ${
|
||||
template.id === displayTemplate.id ? "bg-accent" : ""
|
||||
template.id === templateId ? "bg-accent" : ""
|
||||
}`}
|
||||
onClick={() => handleTemplateSelect(template)}
|
||||
>
|
||||
|
||||
@@ -58,6 +58,12 @@ export function EvaluatorSelector({
|
||||
},
|
||||
);
|
||||
|
||||
// Ensure per-name arrays are sorted by createdAt ascending so last is latest
|
||||
const sortByCreatedAt = (arr: EvalTemplate[]) =>
|
||||
arr.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
||||
Object.values(groupedTemplates.custom).forEach(sortByCreatedAt);
|
||||
Object.values(groupedTemplates.langfuse).forEach(sortByCreatedAt);
|
||||
|
||||
// Filter templates based on search
|
||||
const filteredTemplates = {
|
||||
langfuse: Object.entries(groupedTemplates.langfuse)
|
||||
|
||||
@@ -30,6 +30,7 @@ export default function DefaultEvaluationModelPage() {
|
||||
const projectId = router.query.projectId as string;
|
||||
const utils = api.useUtils();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const hasWriteAccess = useHasProjectAccess({
|
||||
projectId,
|
||||
@@ -56,7 +57,7 @@ export default function DefaultEvaluationModelPage() {
|
||||
setModelParams,
|
||||
);
|
||||
|
||||
const { mutate: upsertDefaultModel, isLoading } =
|
||||
const { mutateAsync: upsertDefaultModel, isLoading } =
|
||||
api.defaultLlmModel.upsertDefaultModel.useMutation({
|
||||
onSuccess: () => {
|
||||
showSuccessToast({
|
||||
@@ -65,26 +66,26 @@ export default function DefaultEvaluationModelPage() {
|
||||
});
|
||||
|
||||
utils.defaultLlmModel.fetchDefaultModel.invalidate({ projectId });
|
||||
setFormError(null);
|
||||
setIsEditing(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
setFormError(error.message as string);
|
||||
},
|
||||
});
|
||||
|
||||
const executeUpsertMutation = () => {
|
||||
try {
|
||||
upsertDefaultModel({
|
||||
projectId,
|
||||
provider: modelParams.provider.value,
|
||||
adapter: modelParams.adapter.value,
|
||||
model: modelParams.model.value,
|
||||
modelParams: {
|
||||
max_tokens: modelParams.max_tokens.value,
|
||||
temperature: modelParams.temperature.value,
|
||||
top_p: modelParams.top_p.value,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
setIsEditing(false);
|
||||
const executeUpsertMutation = async () => {
|
||||
await upsertDefaultModel({
|
||||
projectId,
|
||||
provider: modelParams.provider.value,
|
||||
adapter: modelParams.adapter.value,
|
||||
model: modelParams.model.value,
|
||||
modelParams: {
|
||||
max_tokens: modelParams.max_tokens.value,
|
||||
temperature: modelParams.temperature.value,
|
||||
top_p: modelParams.top_p.value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (isDefaultModelLoading) {
|
||||
@@ -135,7 +136,15 @@ export default function DefaultEvaluationModelPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog open={isEditing} onOpenChange={setIsEditing}>
|
||||
<Dialog
|
||||
open={isEditing}
|
||||
onOpenChange={(open) => {
|
||||
setIsEditing(open);
|
||||
if (!open) {
|
||||
setFormError(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
disabled={!hasWriteAccess}
|
||||
@@ -167,24 +176,31 @@ export default function DefaultEvaluationModelPage() {
|
||||
<div className="my-2 text-xs text-muted-foreground">
|
||||
Select a model which supports function calling.
|
||||
</div>
|
||||
<div className="mt-2 flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setIsEditing(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
{selectedModel ? (
|
||||
<UpdateButton
|
||||
projectId={projectId}
|
||||
isLoading={isLoading}
|
||||
executeUpsertMutation={executeUpsertMutation}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
disabled={!hasWriteAccess || !modelParams.provider.value}
|
||||
onClick={executeUpsertMutation}
|
||||
>
|
||||
Save
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="mt-2 flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setIsEditing(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
{selectedModel ? (
|
||||
<UpdateButton
|
||||
projectId={projectId}
|
||||
isLoading={isLoading}
|
||||
executeUpsertMutation={executeUpsertMutation}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
disabled={!hasWriteAccess || !modelParams.provider.value}
|
||||
onClick={executeUpsertMutation}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{formError ? (
|
||||
<p className="text-red w-full text-center">
|
||||
<span className="font-bold">Error:</span> {formError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -223,7 +239,10 @@ function UpdateButton({
|
||||
Update
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent onClick={(e) => e.stopPropagation()}>
|
||||
<PopoverContent
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-fit max-w-[500px]"
|
||||
>
|
||||
<h2 className="text-md mb-3 font-semibold">Please confirm</h2>
|
||||
<p className="mb-3 text-sm">
|
||||
Updating the default model will impact any currently running
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from "@/src/server/api/trpc";
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
ForbiddenError,
|
||||
InvalidRequestError,
|
||||
LangfuseNotFoundError,
|
||||
ZodModelConfig,
|
||||
@@ -42,12 +43,14 @@ export const defaultEvalModelRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
try {
|
||||
return DefaultEvalModelService.upsertDefaultModel(input);
|
||||
return await DefaultEvalModelService.upsertDefaultModel(input);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidRequestError) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: error.message });
|
||||
} else if (error instanceof LangfuseNotFoundError) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
|
||||
} else if (error instanceof ForbiddenError) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: error.message });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { z } from "zod/v4";
|
||||
import { z as zodV3 } from "zod/v3";
|
||||
import {
|
||||
createTRPCRouter,
|
||||
protectedProjectProcedure,
|
||||
@@ -11,7 +10,6 @@ import {
|
||||
ZodModelConfig,
|
||||
singleFilter,
|
||||
variableMapping,
|
||||
ChatMessageRole,
|
||||
paginationZod,
|
||||
type JobConfiguration,
|
||||
JobType,
|
||||
@@ -21,19 +19,16 @@ import {
|
||||
orderBy,
|
||||
jsonSchema,
|
||||
} from "@langfuse/shared";
|
||||
import { decrypt } from "@langfuse/shared/encryption";
|
||||
import {
|
||||
decryptAndParseExtraHeaders,
|
||||
fetchLLMCompletion,
|
||||
getQueue,
|
||||
getScoresByIds,
|
||||
logger,
|
||||
QueueName,
|
||||
QueueJobs,
|
||||
ChatMessageType,
|
||||
tableColumnsToSqlFilterAndPrefix,
|
||||
orderByToPrismaSql,
|
||||
DefaultEvalModelService,
|
||||
testModelCall,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { EvalReferencedEvaluators } from "@/src/features/evals/types";
|
||||
@@ -808,45 +803,20 @@ export const evalRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
const matchingLLMKey = modelConfig.config.apiKey;
|
||||
|
||||
// Make a test structured output call to validate the LLM key
|
||||
try {
|
||||
(
|
||||
await fetchLLMCompletion({
|
||||
streaming: false,
|
||||
apiKey: decrypt(matchingLLMKey.secretKey), // decrypt the secret key
|
||||
extraHeaders: decryptAndParseExtraHeaders(
|
||||
matchingLLMKey.extraHeaders,
|
||||
),
|
||||
baseURL: matchingLLMKey.baseURL ?? undefined,
|
||||
messages: [
|
||||
{
|
||||
role: ChatMessageRole.User,
|
||||
content: input.prompt,
|
||||
type: ChatMessageType.User,
|
||||
},
|
||||
],
|
||||
modelParams: {
|
||||
provider: modelConfig.config.provider,
|
||||
model: modelConfig.config.model,
|
||||
adapter: matchingLLMKey.adapter,
|
||||
...input.modelParams,
|
||||
},
|
||||
structuredOutputSchema: zodV3.object({
|
||||
score: zodV3.string(),
|
||||
reasoning: zodV3.string(),
|
||||
}),
|
||||
config: matchingLLMKey.config,
|
||||
})
|
||||
).completion;
|
||||
// Make a test structured output call to validate the LLM key
|
||||
await testModelCall({
|
||||
provider: modelConfig.config.provider,
|
||||
model: modelConfig.config.model,
|
||||
apiKey: modelConfig.config.apiKey,
|
||||
modelConfig: input.modelParams,
|
||||
prompt: input.prompt,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
throw new TRPCError({
|
||||
code: "PRECONDITION_FAILED",
|
||||
message:
|
||||
"Selected model is not supported for evaluations. Test tool call failed.",
|
||||
message: `Model configuration not valid for evaluation. ${message}`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -289,6 +289,7 @@ export const llmApiKeyRouter = createTRPCRouter({
|
||||
customModels: true,
|
||||
withDefaultModels: true,
|
||||
extraHeaderKeys: true,
|
||||
config: true,
|
||||
},
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
} from "@/src/components/ui/select";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { organizationNameSchema } from "@/src/features/organizations/utils/organizationNameSchema";
|
||||
import { organizationFormSchema } from "@/src/features/organizations/utils/organizationNameSchema";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { SurveyName } from "@prisma/client";
|
||||
import { env } from "@/src/env.mjs";
|
||||
@@ -35,7 +35,7 @@ export const NewOrganizationForm = ({
|
||||
const { update: updateSession } = useSession();
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(organizationNameSchema),
|
||||
resolver: zodResolver(organizationFormSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
type: "Personal",
|
||||
@@ -50,7 +50,7 @@ export const NewOrganizationForm = ({
|
||||
const watchedType = form.watch("type");
|
||||
const isCloud = Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION);
|
||||
|
||||
function onSubmit(values: z.infer<typeof organizationNameSchema>) {
|
||||
function onSubmit(values: z.infer<typeof organizationFormSchema>) {
|
||||
capture("organizations:new_form_submit");
|
||||
createOrgMutation
|
||||
.mutateAsync({
|
||||
@@ -130,13 +130,10 @@ export const NewOrganizationForm = ({
|
||||
<FormDescription>
|
||||
What would best describe your organization?
|
||||
</FormDescription>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select organization type" />
|
||||
<SelectTrigger ref={field.ref}>
|
||||
<SelectValue placeholder="Please choose" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
@@ -162,13 +159,10 @@ export const NewOrganizationForm = ({
|
||||
<FormDescription>
|
||||
How many people are in your {watchedType}?
|
||||
</FormDescription>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select organization size" />
|
||||
<SelectTrigger ref={field.ref}>
|
||||
<SelectValue placeholder="Please choose" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
|
||||
@@ -18,11 +18,29 @@ const organizationSizeOptions = [
|
||||
"More than 300",
|
||||
] as const;
|
||||
|
||||
// Base schema for org creation, used for server-side validation too
|
||||
export const organizationNameSchema = z.object({
|
||||
name: StringNoHTML.min(3, "Must have at least 3 characters").max(
|
||||
60,
|
||||
"Must have at most 60 characters",
|
||||
),
|
||||
type: z.enum(organizationTypeOptions).optional(),
|
||||
size: z.enum(organizationSizeOptions).optional(),
|
||||
});
|
||||
|
||||
// Extended schema for client-side form validation including type and size,
|
||||
// which are posted separately as a survey response.
|
||||
export const organizationFormSchema = organizationNameSchema
|
||||
.extend({
|
||||
type: z.enum(organizationTypeOptions),
|
||||
size: z.enum(organizationSizeOptions).optional(),
|
||||
})
|
||||
.check((ctx) => {
|
||||
const { type, size } = ctx.value;
|
||||
if ((type === "Company" || type === "Agency") && !size) {
|
||||
ctx.issues.push({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["size"],
|
||||
input: ctx.value.size,
|
||||
message: "Please specify the size of your organization",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -40,6 +40,9 @@ import { env } from "@/src/env.mjs";
|
||||
|
||||
const isLangfuseCloud = Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION);
|
||||
|
||||
const isCustomModelsRequired = (adapter: LLMAdapter) =>
|
||||
adapter === LLMAdapter.Azure || adapter === LLMAdapter.Bedrock;
|
||||
|
||||
const createFormSchema = (mode: "create" | "update") =>
|
||||
z
|
||||
.object({
|
||||
@@ -62,11 +65,7 @@ const createFormSchema = (mode: "create" | "update") =>
|
||||
}),
|
||||
),
|
||||
})
|
||||
.refine((data) => data.withDefaultModels || data.customModels.length > 0, {
|
||||
message:
|
||||
"At least one custom model name is required when default models are disabled.",
|
||||
path: ["withDefaultModels"],
|
||||
})
|
||||
// 1) If adapter requires custom models, enforce that first
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.adapter !== LLMAdapter.Bedrock) return true;
|
||||
@@ -88,6 +87,32 @@ const createFormSchema = (mode: "create" | "update") =>
|
||||
path: ["adapter"],
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (isCustomModelsRequired(data.adapter)) {
|
||||
return data.customModels.length > 0;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "At least one custom model is required for this adapter.",
|
||||
path: ["customModels"],
|
||||
},
|
||||
)
|
||||
// 2) For adapters that support defaults, require default models or at least one custom model
|
||||
.refine(
|
||||
(data) => {
|
||||
if (isCustomModelsRequired(data.adapter)) {
|
||||
return true;
|
||||
}
|
||||
return data.withDefaultModels || data.customModels.length > 0;
|
||||
},
|
||||
{
|
||||
message:
|
||||
"At least one custom model name is required when default models are disabled.",
|
||||
path: ["withDefaultModels"],
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(data) =>
|
||||
data.adapter === LLMAdapter.Bedrock ||
|
||||
@@ -200,6 +225,12 @@ export function CreateLLMApiKeyForm({
|
||||
|
||||
const currentAdapter = form.watch("adapter");
|
||||
|
||||
const hasAdvancedSettings = (adapter: LLMAdapter) =>
|
||||
adapter === LLMAdapter.OpenAI ||
|
||||
adapter === LLMAdapter.Anthropic ||
|
||||
adapter === LLMAdapter.VertexAI ||
|
||||
adapter === LLMAdapter.GoogleAIStudio;
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "customModels",
|
||||
@@ -214,6 +245,114 @@ export function CreateLLMApiKeyForm({
|
||||
name: "extraHeaders",
|
||||
});
|
||||
|
||||
const renderCustomModelsField = () => (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customModels"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>Custom models</FormLabel>
|
||||
<FormDescription>
|
||||
Custom model names accepted by given endpoint.
|
||||
</FormDescription>
|
||||
{currentAdapter === LLMAdapter.Azure && (
|
||||
<FormDescription className="text-dark-yellow">
|
||||
{
|
||||
"For Azure, the model name should be the same as the deployment name in Azure. For evals, choose a model with function calling capabilities."
|
||||
}
|
||||
</FormDescription>
|
||||
)}
|
||||
|
||||
{currentAdapter === LLMAdapter.Bedrock && (
|
||||
<FormDescription className="text-dark-yellow">
|
||||
{
|
||||
"For Bedrock, the model name is the Bedrock Inference Profile ID, e.g. 'eu.anthropic.claude-3-5-sonnet-20240620-v1:0'"
|
||||
}
|
||||
</FormDescription>
|
||||
)}
|
||||
|
||||
{fields.map((customModel, index) => (
|
||||
<span key={customModel.id} className="flex flex-row space-x-2">
|
||||
<Input
|
||||
{...form.register(`customModels.${index}.value`)}
|
||||
placeholder={`Custom model name ${index + 1}`}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</span>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => append({ value: "" })}
|
||||
className="w-full"
|
||||
>
|
||||
<PlusIcon className="-ml-0.5 mr-1.5 h-5 w-5" aria-hidden="true" />
|
||||
Add custom model name
|
||||
</Button>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderExtraHeadersField = () => (
|
||||
<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{" "}
|
||||
{isLangfuseCloud ? "on our servers" : "in your database"}.
|
||||
</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={
|
||||
mode === "update" &&
|
||||
existingKey?.extraHeaderKeys &&
|
||||
existingKey.extraHeaderKeys[index]
|
||||
? "***"
|
||||
: "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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
// Disable provider and adapter fields in update mode
|
||||
const isFieldDisabled = (fieldName: string) => {
|
||||
if (mode !== "update") return false;
|
||||
@@ -285,14 +424,16 @@ export function CreateLLMApiKeyForm({
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const newKey = {
|
||||
const newLlmApiKey = {
|
||||
id: existingKey?.id ?? "",
|
||||
projectId,
|
||||
secretKey: secretKey ?? "",
|
||||
provider: values.provider,
|
||||
adapter: values.adapter,
|
||||
baseURL: values.baseURL || undefined,
|
||||
withDefaultModels: values.withDefaultModels,
|
||||
withDefaultModels: isCustomModelsRequired(currentAdapter)
|
||||
? false
|
||||
: values.withDefaultModels,
|
||||
config,
|
||||
customModels: values.customModels
|
||||
.map((m) => m.value.trim())
|
||||
@@ -303,8 +444,8 @@ export function CreateLLMApiKeyForm({
|
||||
try {
|
||||
const testResult =
|
||||
mode === "create"
|
||||
? await mutTestLLMApiKey.mutateAsync(newKey)
|
||||
: await mutTestUpdateLLMApiKey.mutateAsync(newKey);
|
||||
? await mutTestLLMApiKey.mutateAsync(newLlmApiKey)
|
||||
: await mutTestUpdateLLMApiKey.mutateAsync(newLlmApiKey);
|
||||
|
||||
if (!testResult.success) throw new Error(testResult.error);
|
||||
} catch (error) {
|
||||
@@ -320,7 +461,7 @@ export function CreateLLMApiKeyForm({
|
||||
}
|
||||
|
||||
return (mode === "create" ? mutCreateLlmApiKey : mutUpdateLlmApiKey)
|
||||
.mutateAsync(newKey)
|
||||
.mutateAsync(newLlmApiKey)
|
||||
.then(() => {
|
||||
form.reset();
|
||||
onSuccess();
|
||||
@@ -337,24 +478,6 @@ export function CreateLLMApiKeyForm({
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<DialogBody>
|
||||
{/* Provider name */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="provider"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Provider name</FormLabel>
|
||||
<FormDescription>
|
||||
Name to identify the key within Langfuse.
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={isFieldDisabled("provider")} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* LLM adapter */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -393,6 +516,27 @@ export function CreateLLMApiKeyForm({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{/* Provider name */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="provider"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Provider name</FormLabel>
|
||||
<FormDescription>
|
||||
Key to identify the connection within Langfuse.
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={`e.g. ${currentAdapter}`}
|
||||
disabled={isFieldDisabled("provider")}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* API Key or AWS Credentials */}
|
||||
{currentAdapter === LLMAdapter.Bedrock ? (
|
||||
@@ -563,61 +707,64 @@ export function CreateLLMApiKeyForm({
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="flex items-center pl-0"
|
||||
onClick={() => setShowAdvancedSettings(!showAdvancedSettings)}
|
||||
>
|
||||
<span>
|
||||
{showAdvancedSettings
|
||||
? "Hide advanced settings"
|
||||
: "Show advanced settings"}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`ml-1 h-4 w-4 transition-transform ${showAdvancedSettings ? "rotate-180" : "rotate-0"}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
{/* Custom models: top-level for Azure/Bedrock */}
|
||||
{isCustomModelsRequired(currentAdapter) && renderCustomModelsField()}
|
||||
|
||||
{showAdvancedSettings && (
|
||||
{/* Extra headers - show for Azure in main section (Azure has no advanced settings) */}
|
||||
{currentAdapter === LLMAdapter.Azure && renderExtraHeadersField()}
|
||||
|
||||
{hasAdvancedSettings(currentAdapter) && (
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="flex items-center pl-0"
|
||||
onClick={() => setShowAdvancedSettings(!showAdvancedSettings)}
|
||||
>
|
||||
<span>
|
||||
{showAdvancedSettings
|
||||
? "Hide advanced settings"
|
||||
: "Show advanced settings"}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`ml-1 h-4 w-4 transition-transform ${showAdvancedSettings ? "rotate-180" : "rotate-0"}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasAdvancedSettings(currentAdapter) && showAdvancedSettings && (
|
||||
<div className="space-y-4 border-t pt-4">
|
||||
{/* baseURL */}
|
||||
{currentAdapter !== LLMAdapter.Bedrock &&
|
||||
currentAdapter !== LLMAdapter.Azure && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="baseURL"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Base URL</FormLabel>
|
||||
<FormDescription>
|
||||
Leave blank to use the default base URL for the given
|
||||
LLM adapter.{" "}
|
||||
{currentAdapter === LLMAdapter.OpenAI && (
|
||||
<span>
|
||||
OpenAI default: https://api.openai.com/v1
|
||||
</span>
|
||||
)}
|
||||
{currentAdapter === LLMAdapter.Anthropic && (
|
||||
<span>
|
||||
Anthropic default: https://api.anthropic.com
|
||||
(excluding /v1/messages)
|
||||
</span>
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="baseURL"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Base URL</FormLabel>
|
||||
<FormDescription>
|
||||
Leave blank to use the default base URL for the given LLM
|
||||
adapter.{" "}
|
||||
{currentAdapter === LLMAdapter.OpenAI && (
|
||||
<span>OpenAI default: https://api.openai.com/v1</span>
|
||||
)}
|
||||
{currentAdapter === LLMAdapter.Anthropic && (
|
||||
<span>
|
||||
Anthropic default: https://api.anthropic.com
|
||||
(excluding /v1/messages)
|
||||
</span>
|
||||
)}
|
||||
</FormDescription>
|
||||
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="default" />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="default" />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* VertexAI Location */}
|
||||
{currentAdapter === LLMAdapter.VertexAI && (
|
||||
@@ -643,69 +790,8 @@ export function CreateLLMApiKeyForm({
|
||||
)}
|
||||
|
||||
{/* Extra Headers */}
|
||||
{currentAdapter === LLMAdapter.OpenAI ||
|
||||
currentAdapter === LLMAdapter.Azure ? (
|
||||
<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{" "}
|
||||
{isLangfuseCloud
|
||||
? "on our servers"
|
||||
: "in your database"}
|
||||
.
|
||||
</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={
|
||||
mode === "update" &&
|
||||
existingKey?.extraHeaderKeys &&
|
||||
existingKey.extraHeaderKeys[index]
|
||||
? "***"
|
||||
: "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}
|
||||
{currentAdapter === LLMAdapter.OpenAI &&
|
||||
renderExtraHeadersField()}
|
||||
|
||||
{/* With default models */}
|
||||
<FormField
|
||||
@@ -720,36 +806,11 @@ export function CreateLLMApiKeyForm({
|
||||
Default models for the selected adapter will be
|
||||
available in Langfuse features.
|
||||
</FormDescription>
|
||||
{currentAdapter === LLMAdapter.Azure && (
|
||||
<FormDescription className="text-dark-yellow">
|
||||
Azure LLM adapter does not support default model
|
||||
names maintained by Langfuse. Instead, please add a
|
||||
custom model below that is the same as your
|
||||
deployment name.
|
||||
</FormDescription>
|
||||
)}
|
||||
{currentAdapter === LLMAdapter.Bedrock && (
|
||||
<FormDescription className="text-dark-yellow">
|
||||
Bedrock LLM adapter does not support default model
|
||||
names maintained by Langfuse. Instead, please add
|
||||
the Bedrock model IDs you have enabled in the AWS
|
||||
console.
|
||||
</FormDescription>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<FormControl>
|
||||
<Switch
|
||||
disabled={
|
||||
currentAdapter === LLMAdapter.Azure ||
|
||||
currentAdapter === LLMAdapter.Bedrock
|
||||
}
|
||||
checked={
|
||||
currentAdapter === LLMAdapter.Azure ||
|
||||
currentAdapter === LLMAdapter.Bedrock
|
||||
? false
|
||||
: field.value
|
||||
}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -761,64 +822,8 @@ export function CreateLLMApiKeyForm({
|
||||
/>
|
||||
|
||||
{/* Custom model names */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customModels"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>Custom models</FormLabel>
|
||||
<FormDescription>
|
||||
Custom model names accepted by given endpoint.
|
||||
</FormDescription>
|
||||
{currentAdapter === LLMAdapter.Azure && (
|
||||
<FormDescription className="text-dark-yellow">
|
||||
{
|
||||
"For Azure, the model name should be the same as the deployment name in Azure. For evals, choose a model with function calling capabilities."
|
||||
}
|
||||
</FormDescription>
|
||||
)}
|
||||
|
||||
{currentAdapter === LLMAdapter.Bedrock && (
|
||||
<FormDescription className="text-dark-yellow">
|
||||
{
|
||||
"For Bedrock, the model name is the Bedrock Inference Profile ID, e.g. 'eu.anthropic.claude-3-5-sonnet-20240620-v1:0'"
|
||||
}
|
||||
</FormDescription>
|
||||
)}
|
||||
|
||||
{fields.map((customModel, index) => (
|
||||
<span
|
||||
key={customModel.id}
|
||||
className="flex flex-row space-x-2"
|
||||
>
|
||||
<Input
|
||||
{...form.register(`customModels.${index}.value`)}
|
||||
placeholder={`Custom model name ${index + 1}`}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</span>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => append({ value: "" })}
|
||||
className="w-full"
|
||||
>
|
||||
<PlusIcon
|
||||
className="-ml-0.5 mr-1.5 h-5 w-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Add custom model name
|
||||
</Button>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{!isCustomModelsRequired(currentAdapter) &&
|
||||
renderCustomModelsField()}
|
||||
</div>
|
||||
)}
|
||||
</DialogBody>
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
queryClickhouse,
|
||||
TRACE_TO_OBSERVATIONS_INTERVAL,
|
||||
type DateTimeFilter,
|
||||
getTimeframesTracesAMT,
|
||||
measureAndReturn,
|
||||
} from "@langfuse/shared/src/server";
|
||||
|
||||
type QueryType = {
|
||||
@@ -45,7 +47,7 @@ export const generateDailyMetrics = async (props: QueryType) => {
|
||||
sum(arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, o.usage_details)))) as outputUsage,
|
||||
sumMap(o.usage_details)['total'] as totalUsage,
|
||||
sum(coalesce(o.total_cost, 0)) as totalCost
|
||||
FROM traces t FINAL
|
||||
FROM __TRACE_TABLE__ t FINAL
|
||||
LEFT JOIN observations o FINAL on o.trace_id = t.id AND o.project_id = t.project_id
|
||||
WHERE o.project_id = {projectId: String}
|
||||
AND t.project_id = {projectId: String}
|
||||
@@ -72,7 +74,7 @@ export const generateDailyMetrics = async (props: QueryType) => {
|
||||
SELECT
|
||||
toDate(t.timestamp) as date,
|
||||
count(t.id) as countTraces
|
||||
FROM traces t FINAL
|
||||
FROM __TRACE_TABLE__ t FINAL
|
||||
WHERE t.project_id = {projectId: String}
|
||||
${hasTracesFilter ? `AND ${appliedTracesFilter.query}` : ""}
|
||||
GROUP BY date
|
||||
@@ -90,48 +92,104 @@ export const generateDailyMetrics = async (props: QueryType) => {
|
||||
${props.limit !== undefined && props.page !== undefined ? `LIMIT {limit: Int32} OFFSET {offset: Int32}` : ""}
|
||||
`;
|
||||
|
||||
const result = await queryClickhouse<{
|
||||
date: string;
|
||||
countTraces: number;
|
||||
countObservations: number;
|
||||
totalCost: number;
|
||||
usage: (string | null)[][];
|
||||
}>({
|
||||
query,
|
||||
params: {
|
||||
...appliedTracesFilter.params,
|
||||
...appliedFilter.params,
|
||||
projectId: props.projectId,
|
||||
...(props.limit !== undefined ? { limit: props.limit } : {}),
|
||||
...(props.page !== undefined
|
||||
? { offset: (props.page - 1) * props.limit }
|
||||
: {}),
|
||||
...(timeFilter
|
||||
? {
|
||||
cteTimeFilter: convertDateToClickhouseDateTime(timeFilter.value),
|
||||
}
|
||||
: {}),
|
||||
const timestamp = props.fromTimestamp
|
||||
? new Date(props.fromTimestamp)
|
||||
: timeFilter?.value;
|
||||
|
||||
return measureAndReturn({
|
||||
operationName: "generateDailyMetrics",
|
||||
projectId: props.projectId,
|
||||
minStartTime: timestamp,
|
||||
input: {
|
||||
params: {
|
||||
...appliedTracesFilter.params,
|
||||
...appliedFilter.params,
|
||||
projectId: props.projectId,
|
||||
...(props.limit !== undefined ? { limit: props.limit } : {}),
|
||||
...(props.page !== undefined
|
||||
? { offset: (props.page - 1) * props.limit }
|
||||
: {}),
|
||||
...(timeFilter
|
||||
? {
|
||||
cteTimeFilter: convertDateToClickhouseDateTime(timeFilter.value),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
type: "trace",
|
||||
kind: "daily_metrics",
|
||||
projectId: props.projectId,
|
||||
operation_name: "generateDailyMetrics",
|
||||
},
|
||||
timestamp,
|
||||
},
|
||||
clickhouseConfigs: {
|
||||
request_timeout: 60_000, // Use 1 minute timeout for daily metrics
|
||||
existingExecution: async (input) => {
|
||||
const result = await queryClickhouse<{
|
||||
date: string;
|
||||
countTraces: number;
|
||||
countObservations: number;
|
||||
totalCost: number;
|
||||
usage: (string | null)[][];
|
||||
}>({
|
||||
query: query.replaceAll("__TRACE_TABLE__", "traces"),
|
||||
params: input.params,
|
||||
tags: { ...input.tags, experiment_amt: "original" },
|
||||
clickhouseConfigs: {
|
||||
request_timeout: 60_000, // Use 1 minute timeout for daily metrics
|
||||
},
|
||||
});
|
||||
|
||||
return result.map((record) => ({
|
||||
date: record.date,
|
||||
countTraces: Number(record.countTraces),
|
||||
countObservations: Number(record.countObservations),
|
||||
totalCost: Number(record.totalCost),
|
||||
usage: record.usage.map((u) => ({
|
||||
model: u[0],
|
||||
inputUsage: Number(u[1]),
|
||||
outputUsage: Number(u[2]),
|
||||
totalUsage: Number(u[3]),
|
||||
totalCost: Number(u[4]),
|
||||
countObservations: Number(u[5]),
|
||||
countTraces: Number(u[6]),
|
||||
})),
|
||||
}));
|
||||
},
|
||||
newExecution: async (input) => {
|
||||
const traceAmt = getTimeframesTracesAMT(input.timestamp);
|
||||
const result = await queryClickhouse<{
|
||||
date: string;
|
||||
countTraces: number;
|
||||
countObservations: number;
|
||||
totalCost: number;
|
||||
usage: (string | null)[][];
|
||||
}>({
|
||||
query: query.replaceAll("__TRACE_TABLE__", traceAmt),
|
||||
params: input.params,
|
||||
tags: { ...input.tags, experiment_amt: "new" },
|
||||
clickhouseConfigs: {
|
||||
request_timeout: 60_000, // Use 1 minute timeout for daily metrics
|
||||
},
|
||||
});
|
||||
|
||||
return result.map((record) => ({
|
||||
date: record.date,
|
||||
countTraces: Number(record.countTraces),
|
||||
countObservations: Number(record.countObservations),
|
||||
totalCost: Number(record.totalCost),
|
||||
usage: record.usage.map((u) => ({
|
||||
model: u[0],
|
||||
inputUsage: Number(u[1]),
|
||||
outputUsage: Number(u[2]),
|
||||
totalUsage: Number(u[3]),
|
||||
totalCost: Number(u[4]),
|
||||
countObservations: Number(u[5]),
|
||||
countTraces: Number(u[6]),
|
||||
})),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
return result.map((record) => ({
|
||||
date: record.date,
|
||||
countTraces: Number(record.countTraces),
|
||||
countObservations: Number(record.countObservations),
|
||||
totalCost: Number(record.totalCost),
|
||||
usage: record.usage.map((u) => ({
|
||||
model: u[0],
|
||||
inputUsage: Number(u[1]),
|
||||
outputUsage: Number(u[2]),
|
||||
totalUsage: Number(u[3]),
|
||||
totalCost: Number(u[4]),
|
||||
countObservations: Number(u[5]),
|
||||
countTraces: Number(u[6]),
|
||||
})),
|
||||
}));
|
||||
};
|
||||
|
||||
export const getDailyMetricsCount = async (props: QueryType) => {
|
||||
@@ -145,16 +203,48 @@ export const getDailyMetricsCount = async (props: QueryType) => {
|
||||
|
||||
const query = `
|
||||
SELECT count(distinct toDate(timestamp)) as count
|
||||
FROM traces t
|
||||
FROM __TRACE_TABLE__ t
|
||||
WHERE project_id = {projectId: String}
|
||||
${filter.length() > 0 ? `AND ${appliedFilter.query}` : ""}
|
||||
`;
|
||||
|
||||
const records = await queryClickhouse<{ count: string }>({
|
||||
query,
|
||||
params: { ...appliedFilter.params, projectId: props.projectId },
|
||||
const timestamp = props.fromTimestamp
|
||||
? new Date(props.fromTimestamp)
|
||||
: undefined;
|
||||
|
||||
return measureAndReturn({
|
||||
operationName: "getDailyMetricsCount",
|
||||
projectId: props.projectId,
|
||||
minStartTime: timestamp,
|
||||
input: {
|
||||
params: { ...appliedFilter.params, projectId: props.projectId },
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
type: "trace",
|
||||
kind: "daily_metrics_count",
|
||||
projectId: props.projectId,
|
||||
operation_name: "getDailyMetricsCount",
|
||||
},
|
||||
timestamp,
|
||||
},
|
||||
existingExecution: async (input) => {
|
||||
const records = await queryClickhouse<{ count: string }>({
|
||||
query: query.replace("__TRACE_TABLE__", "traces"),
|
||||
params: input.params,
|
||||
tags: { ...input.tags, experiment_amt: "original" },
|
||||
});
|
||||
return records.map((record) => Number(record.count)).shift();
|
||||
},
|
||||
newExecution: async (input) => {
|
||||
const traceAmt = getTimeframesTracesAMT(input.timestamp);
|
||||
const records = await queryClickhouse<{ count: string }>({
|
||||
query: query.replace("__TRACE_TABLE__", traceAmt),
|
||||
params: input.params,
|
||||
tags: { ...input.tags, experiment_amt: "new" },
|
||||
});
|
||||
return records.map((record) => Number(record.count)).shift();
|
||||
},
|
||||
});
|
||||
return records.map((record) => Number(record.count)).shift();
|
||||
};
|
||||
|
||||
const filterParams = [
|
||||
|
||||
@@ -14,6 +14,19 @@ const isUniqueConstraintError = (error: any): boolean => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create or fetch a dataset run with optimistic concurrency handling.
|
||||
*
|
||||
* Behavior:
|
||||
* - First tries to find an existing run by (projectId, datasetId, name).
|
||||
* - If not found, attempts to create it.
|
||||
* - If creation fails due to a unique constraint (likely created concurrently),
|
||||
* fetches and returns the existing run.
|
||||
* - If all steps fail, throws an error.
|
||||
*
|
||||
* Rationale: The public API can receive many POST requests almost simultaneously,
|
||||
* which is not concurrency-safe without this guard.
|
||||
*/
|
||||
export const createOrFetchDatasetRun = async ({
|
||||
projectId,
|
||||
datasetId,
|
||||
@@ -28,7 +41,21 @@ export const createOrFetchDatasetRun = async ({
|
||||
metadata?: Json | null;
|
||||
}) => {
|
||||
try {
|
||||
// Attempt optimistic creation
|
||||
// Attempt to fetch existing run
|
||||
const existingRun = await prisma.datasetRuns.findUnique({
|
||||
where: {
|
||||
datasetId_projectId_name: {
|
||||
datasetId,
|
||||
projectId,
|
||||
name: name,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (existingRun) {
|
||||
return existingRun;
|
||||
}
|
||||
|
||||
// Attempt creation
|
||||
const datasetRun = await prisma.datasetRuns.create({
|
||||
data: {
|
||||
id: v4(),
|
||||
|
||||
@@ -84,10 +84,9 @@ export const generateTracesForPublicApi = async ({
|
||||
SELECT
|
||||
trace_id,
|
||||
project_id,
|
||||
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,
|
||||
groupArray(id) as observation_ids
|
||||
FROM observations FINAL
|
||||
${includeMetrics ? "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, " : ""}
|
||||
groupUniqArray(id) as observation_ids
|
||||
FROM observations ${includeMetrics ? "FINAL" : ""}
|
||||
WHERE project_id = {projectId: String}
|
||||
${timeFilter ? `AND start_time >= {cteTimeFilter: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}` : ""}
|
||||
${environmentFilter.length() > 0 ? `AND ${appliedEnvironmentFilter.query}` : ""}
|
||||
@@ -294,16 +293,48 @@ export const getTracesCountForPublicApi = async ({
|
||||
|
||||
const query = `
|
||||
SELECT count() as count
|
||||
FROM traces t
|
||||
FROM __TRACE_TABLE__ t
|
||||
WHERE project_id = {projectId: String}
|
||||
${filter.length() > 0 ? `AND ${appliedFilter.query}` : ""}
|
||||
`;
|
||||
|
||||
const records = await queryClickhouse<{ count: string }>({
|
||||
query,
|
||||
params: { ...appliedFilter.params, projectId: props.projectId },
|
||||
const timestamp = props.fromTimestamp
|
||||
? new Date(props.fromTimestamp)
|
||||
: undefined;
|
||||
|
||||
return measureAndReturn({
|
||||
operationName: "getTracesCountForPublicApi",
|
||||
projectId: props.projectId,
|
||||
minStartTime: timestamp,
|
||||
input: {
|
||||
params: { ...appliedFilter.params, projectId: props.projectId },
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
type: "trace",
|
||||
kind: "count",
|
||||
projectId: props.projectId,
|
||||
operation_name: "getTracesCountForPublicApi",
|
||||
},
|
||||
timestamp,
|
||||
},
|
||||
existingExecution: async (input) => {
|
||||
const records = await queryClickhouse<{ count: string }>({
|
||||
query: query.replace("__TRACE_TABLE__", "traces"),
|
||||
params: input.params,
|
||||
tags: { ...input.tags, experiment_amt: "original" },
|
||||
});
|
||||
return records.map((record) => Number(record.count)).shift();
|
||||
},
|
||||
newExecution: async (input) => {
|
||||
const traceAmt = getTimeframesTracesAMT(input.timestamp);
|
||||
const records = await queryClickhouse<{ count: string }>({
|
||||
query: query.replace("__TRACE_TABLE__", traceAmt),
|
||||
params: input.params,
|
||||
tags: { ...input.tags, experiment_amt: "new" },
|
||||
});
|
||||
return records.map((record) => Number(record.count)).shift();
|
||||
},
|
||||
});
|
||||
return records.map((record) => Number(record.count)).shift();
|
||||
};
|
||||
|
||||
const orderByColumns = [
|
||||
|
||||
@@ -57,6 +57,17 @@ export const GetAnnotationQueuesResponse = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
// POST /annotation-queues
|
||||
export const CreateAnnotationQueueBody = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
description: z.string().nullable(),
|
||||
scoreConfigIds: z.array(z.string()).min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const CreateAnnotationQueueResponse = AnnotationQueueSchema;
|
||||
|
||||
// GET /annotation-queues/:queueId
|
||||
export const GetAnnotationQueueByIdQuery = z
|
||||
.object({
|
||||
|
||||
@@ -117,10 +117,71 @@ export class QueryBuilder {
|
||||
});
|
||||
}
|
||||
|
||||
private validateFilters(
|
||||
filters: z.infer<typeof queryModel>["filters"],
|
||||
view: ViewDeclarationType,
|
||||
) {
|
||||
for (const filter of filters) {
|
||||
// Validate filters on dimension fields
|
||||
if (filter.column in view.dimensions) {
|
||||
const dimension = view.dimensions[filter.column];
|
||||
|
||||
// Array fields (like tags) validation
|
||||
if (dimension.type === "string[]") {
|
||||
if (filter.type === "string") {
|
||||
throw new InvalidRequestError(
|
||||
`Invalid filter for field '${filter.column}': Array fields require type 'arrayOptions', not 'string'. ` +
|
||||
`Use operators like 'any of', 'all of', or 'none of' with an array of values.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Additional validation: ensure value is array for arrayOptions
|
||||
if (filter.type === "arrayOptions" && !Array.isArray(filter.value)) {
|
||||
throw new InvalidRequestError(
|
||||
`Invalid filter for field '${filter.column}': arrayOptions type requires an array of values, not '${typeof filter.value}'.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Special validation for metadata filters
|
||||
else if (filter.column === "metadata") {
|
||||
if (filter.type !== "stringObject") {
|
||||
throw new InvalidRequestError(
|
||||
`Invalid filter for field 'metadata': Metadata filters require type 'stringObject' with a 'key' property, not '${filter.type}'. ` +
|
||||
`Example: {"column": "metadata", "type": "stringObject", "key": "environment", "operator": "=", "value": "production"}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Validate stringObject has required key
|
||||
if (filter.type === "stringObject" && !("key" in filter)) {
|
||||
throw new InvalidRequestError(
|
||||
`Invalid filter for field 'metadata': stringObject type requires a 'key' property to specify which metadata field to filter on. ` +
|
||||
`Example: {"column": "metadata", "type": "stringObject", "key": "environment", "operator": "=", "value": "production"}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Validate stringObject value type
|
||||
if (
|
||||
filter.type === "stringObject" &&
|
||||
typeof filter.value !== "string"
|
||||
) {
|
||||
throw new InvalidRequestError(
|
||||
// @ts-ignore
|
||||
`Invalid filter for field 'metadata': stringObject type requires a string value, not '${typeof filter.value}'.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private mapFilters(
|
||||
filters: z.infer<typeof queryModel>["filters"],
|
||||
view: ViewDeclarationType,
|
||||
) {
|
||||
// Validate all filters before processing
|
||||
this.validateFilters(filters, view);
|
||||
|
||||
// Transform our filters to match the column mapping format expected by createFilterFromFilterState
|
||||
const columnMappings = filters.map((filter) => {
|
||||
let clickhouseSelect: string;
|
||||
|
||||
@@ -9,51 +9,71 @@ type TagInputProps = React.ComponentPropsWithoutRef<
|
||||
typeof CommandPrimitive.Input
|
||||
> & {
|
||||
selectedTags: string[];
|
||||
setSelectedTags: (tags: string[]) => void;
|
||||
setSelectedTags?: (tags: string[]) => void;
|
||||
allowTagRemoval?: boolean;
|
||||
};
|
||||
|
||||
export const TagInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
TagInputProps
|
||||
>(({ className, selectedTags, setSelectedTags, ...props }, ref) => {
|
||||
const capture = usePostHogClientCapture();
|
||||
return (
|
||||
<div
|
||||
className="flex flex-wrap items-center overflow-auto rounded-lg border px-2"
|
||||
cmdk-input-wrapper=""
|
||||
>
|
||||
{selectedTags.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 pt-2">
|
||||
{selectedTags.map((tag: string) => (
|
||||
<Button
|
||||
key={tag}
|
||||
variant="tertiary"
|
||||
size="icon-sm"
|
||||
onClick={() => {
|
||||
const newTags = selectedTags.filter((t) => t !== tag);
|
||||
setSelectedTags(newTags);
|
||||
capture("tag:remove_tag", {
|
||||
name: tag,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
<X className="ml-1 h-3 w-3" />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"placeholder:muted-foreground flex h-8 w-full rounded-md border-transparent bg-transparent px-1 text-sm outline-none focus:border-0 focus:border-none focus:border-transparent focus:ring-0 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
selectedTags,
|
||||
setSelectedTags,
|
||||
allowTagRemoval = false,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const capture = usePostHogClientCapture();
|
||||
|
||||
const removeTag = (tagToRemove: string) => {
|
||||
if (setSelectedTags && allowTagRemoval) {
|
||||
setSelectedTags(selectedTags.filter((t) => t !== tagToRemove));
|
||||
capture("tag:remove_tag", {
|
||||
name: tagToRemove,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-wrap items-center overflow-auto rounded-lg border px-2"
|
||||
cmdk-input-wrapper=""
|
||||
>
|
||||
{selectedTags.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 pt-2">
|
||||
{selectedTags.map((tag: string) => (
|
||||
<Button
|
||||
key={tag}
|
||||
variant="tertiary"
|
||||
size="icon-sm"
|
||||
disabled={!allowTagRemoval}
|
||||
className={
|
||||
allowTagRemoval ? "cursor-pointer" : "cursor-default"
|
||||
}
|
||||
onClick={allowTagRemoval ? () => removeTag(tag) : undefined}
|
||||
>
|
||||
{tag}
|
||||
{allowTagRemoval && <X className="ml-1 h-3 w-3" />}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
autoFocus
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"placeholder:muted-foreground flex h-8 w-full rounded-md border-transparent bg-transparent px-1 text-sm outline-none focus:border-0 focus:border-none focus:border-transparent focus:ring-0 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
autoFocus
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
TagInput.displayName = CommandPrimitive.Input.displayName;
|
||||
|
||||
+3
@@ -22,6 +22,7 @@ type TagManagerProps = {
|
||||
mutateTags: (value: string[]) => void;
|
||||
className?: string;
|
||||
isTableCell?: boolean;
|
||||
allowTagRemoval?: boolean;
|
||||
};
|
||||
|
||||
const TagManager = ({
|
||||
@@ -33,6 +34,7 @@ const TagManager = ({
|
||||
mutateTags,
|
||||
className,
|
||||
isTableCell = false,
|
||||
allowTagRemoval = true,
|
||||
}: TagManagerProps) => {
|
||||
const {
|
||||
selectedTags,
|
||||
@@ -114,6 +116,7 @@ const TagManager = ({
|
||||
onValueChange={setInputValue}
|
||||
selectedTags={selectedTags}
|
||||
setSelectedTags={setSelectedTags}
|
||||
allowTagRemoval={allowTagRemoval}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandGroup
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from "react";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import { type RouterOutput } from "@/src/utils/types";
|
||||
import TagManager from "@/src/features/tag/components/TagMananger";
|
||||
import TagManager from "@/src/features/tag/components/TagManager";
|
||||
import { trpcErrorToast } from "@/src/utils/trpcErrorToast";
|
||||
|
||||
type TagPromptDetailsPopoverProps = {
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from "react";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import { type RouterOutput, type RouterInput } from "@/src/utils/types";
|
||||
import TagManager from "@/src/features/tag/components/TagMananger";
|
||||
import TagManager from "@/src/features/tag/components/TagManager";
|
||||
import { trpcErrorToast } from "@/src/utils/trpcErrorToast";
|
||||
|
||||
type TagPromptPopverProps = {
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from "react";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import { type RouterOutput } from "@/src/utils/types";
|
||||
import TagManager from "@/src/features/tag/components/TagMananger";
|
||||
import TagManager from "@/src/features/tag/components/TagManager";
|
||||
import { trpcErrorToast } from "@/src/utils/trpcErrorToast";
|
||||
|
||||
type TagTraceDetailsPopoverProps = {
|
||||
@@ -85,6 +85,7 @@ export function TagTraceDetailsPopover({
|
||||
isLoading={isLoading}
|
||||
mutateTags={mutateTags}
|
||||
className={className}
|
||||
allowTagRemoval={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from "react";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import { type RouterOutput, type RouterInput } from "@/src/utils/types";
|
||||
import TagManager from "@/src/features/tag/components/TagMananger";
|
||||
import TagManager from "@/src/features/tag/components/TagManager";
|
||||
import { trpcErrorToast } from "@/src/utils/trpcErrorToast";
|
||||
|
||||
type TagTracePopoverProps = {
|
||||
@@ -75,6 +75,7 @@ export function TagTracePopover({
|
||||
mutateTags={mutateTags}
|
||||
className={className}
|
||||
isTableCell
|
||||
allowTagRemoval={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,13 +56,6 @@ export default async function handler(
|
||||
return;
|
||||
}
|
||||
|
||||
const body = ManageBullBody.safeParse(req.body);
|
||||
|
||||
if (!body.success) {
|
||||
res.status(400).json({ error: body.error });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET") {
|
||||
const queues: string[] = Object.values(QueueName);
|
||||
queues.push(...IngestionQueue.getShardNames());
|
||||
@@ -94,6 +87,13 @@ export default async function handler(
|
||||
return res.status(200).json(queueCounts);
|
||||
}
|
||||
|
||||
const body = ManageBullBody.safeParse(req.body);
|
||||
|
||||
if (!body.success) {
|
||||
res.status(400).json({ error: body.error });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && body.data.action === "remove") {
|
||||
logger.info(
|
||||
`Removing jobs for queues ${body.data.queueNames.join(", ")}`,
|
||||
|
||||
@@ -2,12 +2,14 @@ import { prisma } from "@langfuse/shared/src/db";
|
||||
import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares";
|
||||
import { createAuthedProjectAPIRoute } from "@/src/features/public-api/server/createAuthedProjectAPIRoute";
|
||||
import {
|
||||
CreateAnnotationQueueBody,
|
||||
CreateAnnotationQueueResponse,
|
||||
GetAnnotationQueuesQuery,
|
||||
GetAnnotationQueuesResponse,
|
||||
} from "@/src/features/public-api/types/annotation-queues";
|
||||
import { InvalidRequestError, MethodNotAllowedError } from "@langfuse/shared";
|
||||
|
||||
export default withMiddlewares({
|
||||
// NOTE: Post API requires entitlement check
|
||||
GET: createAuthedProjectAPIRoute({
|
||||
name: "Get annotation queues",
|
||||
querySchema: GetAnnotationQueuesQuery,
|
||||
@@ -54,4 +56,72 @@ export default withMiddlewares({
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
POST: createAuthedProjectAPIRoute({
|
||||
name: "Create annotation queue",
|
||||
bodySchema: CreateAnnotationQueueBody,
|
||||
responseSchema: CreateAnnotationQueueResponse,
|
||||
fn: async ({ body, auth }) => {
|
||||
// entitlement check
|
||||
if (auth.scope.plan === "cloud:hobby") {
|
||||
if (
|
||||
(await prisma.annotationQueue.count({
|
||||
where: {
|
||||
projectId: auth.scope.projectId,
|
||||
},
|
||||
})) >= 1
|
||||
) {
|
||||
throw new MethodNotAllowedError(
|
||||
"Maximum number of annotation queues reached on Hobby plan.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const existingQueue = await prisma.annotationQueue.findFirst({
|
||||
where: {
|
||||
projectId: auth.scope.projectId,
|
||||
name: body.name,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingQueue) {
|
||||
throw new InvalidRequestError("A queue with this name already exists.");
|
||||
}
|
||||
|
||||
// verify the score configs exist
|
||||
const scoreConfigs = await prisma.scoreConfig.findMany({
|
||||
where: {
|
||||
id: { in: body.scoreConfigIds },
|
||||
projectId: auth.scope.projectId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
const scoreConfigIdSet = new Set(scoreConfigs.map((config) => config.id));
|
||||
if (body.scoreConfigIds.some((id) => !scoreConfigIdSet.has(id))) {
|
||||
throw new InvalidRequestError(
|
||||
"At least one of the score config IDs cannot be found for the given project.",
|
||||
);
|
||||
}
|
||||
|
||||
const queue = await prisma.annotationQueue.create({
|
||||
data: {
|
||||
projectId: auth.scope.projectId,
|
||||
name: body.name,
|
||||
description: body.description,
|
||||
scoreConfigIds: body.scoreConfigIds,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: queue.id,
|
||||
name: queue.name,
|
||||
description: queue.description,
|
||||
scoreConfigIds: queue.scoreConfigIds,
|
||||
createdAt: queue.createdAt,
|
||||
updatedAt: queue.updatedAt,
|
||||
};
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { prisma } from "@langfuse/shared/src/db";
|
||||
import {
|
||||
convertDateToClickhouseDateTime,
|
||||
logger,
|
||||
measureAndReturn,
|
||||
queryClickhouse,
|
||||
traceException,
|
||||
} from "@langfuse/shared/src/server";
|
||||
@@ -37,20 +38,45 @@ export default async function handler(
|
||||
try {
|
||||
if (failIfNoRecentEvents) {
|
||||
const now = new Date();
|
||||
const traces = await queryClickhouse({
|
||||
query: `
|
||||
SELECT id
|
||||
FROM traces
|
||||
WHERE timestamp <= {now: DateTime64(3)}
|
||||
AND timestamp >= {now: DateTime64(3)} - INTERVAL 3 MINUTE
|
||||
LIMIT 1
|
||||
`,
|
||||
params: {
|
||||
const traces = await measureAndReturn({
|
||||
operationName: "healthCheckTraces",
|
||||
projectId: "__CROSS_PROJECT__",
|
||||
input: {
|
||||
now: convertDateToClickhouseDateTime(now),
|
||||
},
|
||||
tags: {
|
||||
feature: "health-check",
|
||||
type: "trace",
|
||||
existingExecution: async (input: { now: string }) => {
|
||||
return queryClickhouse<{ id: string }>({
|
||||
query: `
|
||||
SELECT id
|
||||
FROM traces
|
||||
WHERE timestamp <= {now: DateTime64(3)}
|
||||
AND timestamp >= {now: DateTime64(3)} - INTERVAL 3 MINUTE
|
||||
LIMIT 1
|
||||
`,
|
||||
params: input,
|
||||
tags: {
|
||||
feature: "health-check",
|
||||
type: "trace",
|
||||
experiment_amt: "original",
|
||||
},
|
||||
});
|
||||
},
|
||||
newExecution: async (input: { now: string }) => {
|
||||
return queryClickhouse<{ id: string }>({
|
||||
query: `
|
||||
SELECT id
|
||||
FROM traces_7d_amt
|
||||
WHERE start_time <= {now: DateTime64(3)}
|
||||
AND start_time >= {now: DateTime64(3)} - INTERVAL 3 MINUTE
|
||||
LIMIT 1
|
||||
`,
|
||||
params: input,
|
||||
tags: {
|
||||
feature: "health-check",
|
||||
type: "trace",
|
||||
experiment_amt: "new",
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
const observations = await queryClickhouse({
|
||||
|
||||
@@ -2,7 +2,6 @@ import { VERSION } from "@/src/constants/VERSION";
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { createTRPCRouter, publicProcedure } from "@/src/server/api/trpc";
|
||||
import { logger } from "@langfuse/shared/src/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const versionSchema = z.string().regex(/^v\d+\.\d+\.\d+(?:[-+].+)?$/); // e.g. v1.2.3, v1.2.3-rc.1, v1.2.3+build.123
|
||||
@@ -79,27 +78,33 @@ export const publicRouter = createTRPCRouter({
|
||||
);
|
||||
body = await response.json();
|
||||
} catch (error) {
|
||||
logger.info(
|
||||
logger.error(
|
||||
"[trpc.public.checkUpdate] failed to fetch latest-release api",
|
||||
{
|
||||
error,
|
||||
},
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const releases = ReleaseApiRes.safeParse(body);
|
||||
if (!releases.success) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Release API response is invalid",
|
||||
});
|
||||
logger.error(
|
||||
"[trpc.public.checkUpdate] Release API response is invalid, does not match schema",
|
||||
{
|
||||
error: releases.error,
|
||||
},
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const langfuseRelease = releases.data.find(
|
||||
(release) => release.repo === "langfuse/langfuse",
|
||||
);
|
||||
if (!langfuseRelease) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Release API response is invalid",
|
||||
});
|
||||
logger.error(
|
||||
"[trpc.public.checkUpdate] Release API response is invalid, does not contain langfuse/langfuse",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const updateType = compareVersions(VERSION, langfuseRelease.latestRelease);
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "worker",
|
||||
"version": "3.96.2",
|
||||
"version": "3.97.4",
|
||||
"description": "",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
|
||||
@@ -588,26 +588,7 @@ describe("create experiment job calls with langfuse server side tracing", async
|
||||
await createExperimentJobPostgres({ event: mockEvent });
|
||||
|
||||
// Verify callLLM was called with correct trace parameters
|
||||
expect(callLLM).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.any(Array),
|
||||
expect.any(Object),
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
environment: PROMPT_EXPERIMENT_ENVIRONMENT,
|
||||
traceName: expect.stringMatching(/^dataset-run-item-/),
|
||||
traceId: expect.any(String),
|
||||
projectId: mockEvent.projectId,
|
||||
authCheck: expect.objectContaining({
|
||||
validKey: true,
|
||||
scope: expect.objectContaining({
|
||||
projectId: mockEvent.projectId,
|
||||
accessLevel: "project",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(callLLM).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ type MigrationState = {
|
||||
maxDate: string | undefined;
|
||||
minDate: string | undefined;
|
||||
queryTimeoutMinutes: number | undefined;
|
||||
targetTracesAllAmtOnly: boolean | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -35,6 +36,7 @@ async function checkQueryExists(
|
||||
SELECT COUNT(*) > 0 AS exists
|
||||
FROM ${queryLogTable}
|
||||
WHERE query_id = '${queryId}'
|
||||
${env.CLICKHOUSE_CLUSTER_ENABLED === "true" ? " SETTINGS skip_unavailable_shards = 1 " : ""}
|
||||
`,
|
||||
format: "JSONEachRow",
|
||||
});
|
||||
@@ -60,6 +62,7 @@ async function checkCompletedQuery(
|
||||
FROM ${queryLogTable}
|
||||
WHERE query_id = '${queryId}' AND type != 'QueryStart'
|
||||
LIMIT 1
|
||||
${env.CLICKHOUSE_CLUSTER_ENABLED === "true" ? " SETTINGS skip_unavailable_shards = 1 " : ""}
|
||||
`,
|
||||
format: "JSONEachRow",
|
||||
});
|
||||
@@ -89,9 +92,7 @@ async function executeLongRunningQuery(
|
||||
const abortController = new AbortController();
|
||||
const timeoutMs = timeoutMinutes * 60 * 1000;
|
||||
|
||||
logger.info(
|
||||
`[Background Migration] Executing traces_null backfill query ${queryId}`,
|
||||
);
|
||||
logger.info(`[Background Migration] Executing backfill query ${queryId}`);
|
||||
|
||||
// Start the query execution
|
||||
const queryPromise = client.command({
|
||||
@@ -206,7 +207,7 @@ export default class MigrateTracesToTracesAMTs implements IBackgroundMigration {
|
||||
};
|
||||
}
|
||||
|
||||
// Check if new ClickHouse tables exists
|
||||
// Check if required ClickHouse tables exist
|
||||
const tables = await clickhouseClient().query({
|
||||
query: "SHOW TABLES",
|
||||
});
|
||||
@@ -247,6 +248,10 @@ export default class MigrateTracesToTracesAMTs implements IBackgroundMigration {
|
||||
const queryTimeoutMinutes =
|
||||
initialMigrationState.state?.queryTimeoutMinutes ??
|
||||
(args.queryTimeoutMinutes as number | undefined);
|
||||
const targetTracesAllAmtOnly =
|
||||
initialMigrationState.state?.targetTracesAllAmtOnly ??
|
||||
(args.targetTracesAllAmtOnly as boolean | undefined) ??
|
||||
false;
|
||||
|
||||
const maxDate = initialMigrationState.state?.maxDate
|
||||
? new Date(initialMigrationState.state.maxDate)
|
||||
@@ -262,6 +267,7 @@ export default class MigrateTracesToTracesAMTs implements IBackgroundMigration {
|
||||
maxDate,
|
||||
minDate,
|
||||
queryTimeoutMinutes,
|
||||
targetTracesAllAmtOnly,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -270,14 +276,21 @@ export default class MigrateTracesToTracesAMTs implements IBackgroundMigration {
|
||||
const queryStart = Date.now();
|
||||
|
||||
// @ts-ignore
|
||||
const migrationState: { state: { maxDate: string; minDate: string } } =
|
||||
await prisma.backgroundMigration.findUniqueOrThrow({
|
||||
where: { id: backgroundMigrationId },
|
||||
select: { state: true },
|
||||
});
|
||||
const migrationState: {
|
||||
state: {
|
||||
maxDate: string;
|
||||
minDate: string;
|
||||
targetTracesAllAmtOnly?: boolean;
|
||||
};
|
||||
} = await prisma.backgroundMigration.findUniqueOrThrow({
|
||||
where: { id: backgroundMigrationId },
|
||||
select: { state: true },
|
||||
});
|
||||
|
||||
const maxDate = new Date(migrationState.state.maxDate);
|
||||
const minDate = new Date(migrationState.state.minDate);
|
||||
const targetTracesAllAmtOnly =
|
||||
migrationState.state.targetTracesAllAmtOnly ?? false;
|
||||
|
||||
// Get current month in YYYYMM format
|
||||
const currentMonth = maxDate.toISOString().slice(0, 7).replace("-", "");
|
||||
@@ -285,45 +298,89 @@ export default class MigrateTracesToTracesAMTs implements IBackgroundMigration {
|
||||
`[Background Migration] Migrating traces for ${currentMonth}`,
|
||||
);
|
||||
|
||||
const query = `
|
||||
INSERT INTO traces_null
|
||||
SELECT
|
||||
-- Identifiers
|
||||
project_id,
|
||||
id,
|
||||
timestamp as start_time,
|
||||
null as end_time,
|
||||
name,
|
||||
|
||||
-- Metadata properties
|
||||
metadata,
|
||||
user_id,
|
||||
session_id,
|
||||
environment,
|
||||
tags,
|
||||
version,
|
||||
release,
|
||||
|
||||
-- UI Properties
|
||||
bookmarked,
|
||||
public,
|
||||
|
||||
-- Aggregations (ignored)
|
||||
[] as observation_ids,
|
||||
[] as score_ids,
|
||||
map() as cost_details,
|
||||
map() as usage_details,
|
||||
|
||||
-- Input/Output
|
||||
input,
|
||||
output,
|
||||
|
||||
created_at,
|
||||
updated_at,
|
||||
event_ts
|
||||
FROM traces
|
||||
WHERE toYYYYMM(timestamp) = ${currentMonth}
|
||||
`;
|
||||
const targetTable = targetTracesAllAmtOnly
|
||||
? "traces_all_amt"
|
||||
: "traces_null";
|
||||
|
||||
const query = targetTracesAllAmtOnly
|
||||
? `
|
||||
INSERT INTO ${targetTable}
|
||||
SELECT
|
||||
-- Identifiers
|
||||
project_id,
|
||||
id,
|
||||
t.timestamp as timestamp,
|
||||
t.timestamp as start_time,
|
||||
t.timestamp as end_time,
|
||||
name,
|
||||
|
||||
-- Metadata properties
|
||||
metadata,
|
||||
user_id,
|
||||
session_id,
|
||||
environment,
|
||||
tags,
|
||||
version,
|
||||
release,
|
||||
|
||||
-- UI Properties
|
||||
arrayReduce('argMaxState', [toNullable(bookmarked)], [event_ts]) as bookmarked,
|
||||
arrayReduce('argMaxState', [toNullable(public)], [event_ts]) as public,
|
||||
|
||||
-- Aggregations
|
||||
[] as observation_ids,
|
||||
[] as score_ids,
|
||||
map() as cost_details,
|
||||
map() as usage_details,
|
||||
|
||||
-- Input/Output
|
||||
arrayReduce('argMaxState', [coalesce(input, '')], [if(coalesce(input, '') <> '', event_ts, toDateTime64(0, 3))]) as input,
|
||||
arrayReduce('argMaxState', [coalesce(output, '')], [if(coalesce(output, '') <> '', event_ts, toDateTime64(0, 3))]) as output,
|
||||
|
||||
created_at,
|
||||
updated_at
|
||||
FROM traces t
|
||||
WHERE toYYYYMM(t.timestamp) = ${currentMonth}
|
||||
`
|
||||
: `
|
||||
INSERT INTO ${targetTable}
|
||||
SELECT
|
||||
-- Identifiers
|
||||
project_id,
|
||||
id,
|
||||
timestamp as start_time,
|
||||
null as end_time,
|
||||
name,
|
||||
|
||||
-- Metadata properties
|
||||
metadata,
|
||||
user_id,
|
||||
session_id,
|
||||
environment,
|
||||
tags,
|
||||
version,
|
||||
release,
|
||||
|
||||
-- UI Properties
|
||||
bookmarked,
|
||||
public,
|
||||
|
||||
-- Aggregations (ignored)
|
||||
[] as observation_ids,
|
||||
[] as score_ids,
|
||||
map() as cost_details,
|
||||
map() as usage_details,
|
||||
|
||||
-- Input/Output
|
||||
input,
|
||||
output,
|
||||
|
||||
created_at,
|
||||
updated_at,
|
||||
event_ts
|
||||
FROM traces
|
||||
WHERE toYYYYMM(timestamp) = ${currentMonth}
|
||||
`;
|
||||
|
||||
await executeLongRunningQuery(query, queryTimeoutMinutes ?? 90);
|
||||
|
||||
@@ -339,7 +396,7 @@ export default class MigrateTracesToTracesAMTs implements IBackgroundMigration {
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`[Background Migration] Inserted traces into traces_null for ${currentMonth} in ${Date.now() - queryStart}ms`,
|
||||
`[Background Migration] Inserted traces into ${targetTable} for ${currentMonth} in ${Date.now() - queryStart}ms`,
|
||||
);
|
||||
|
||||
if (maxDate < minDate) {
|
||||
@@ -410,6 +467,10 @@ async function main() {
|
||||
short: "t",
|
||||
default: "90",
|
||||
},
|
||||
targetTracesAllAmtOnly: {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const VERSION = "v3.96.2";
|
||||
export const VERSION = "v3.97.4";
|
||||
|
||||
@@ -1873,8 +1873,8 @@
|
||||
},
|
||||
{
|
||||
"id": "cmcnjkrfa000207l4fpnh5mnv",
|
||||
"model_name": "gemini-2.5-flash-lite-preview-06-17",
|
||||
"match_pattern": "(?i)^(gemini-2.5-flash-lite-preview-06-17)$",
|
||||
"model_name": "gemini-2.5-flash-lite",
|
||||
"match_pattern": "(?i)^(gemini-2.5-flash-lite)$",
|
||||
"created_at": "2025-07-03T13:44:06.964Z",
|
||||
"updated_at": "2025-07-03T13:44:06.964Z",
|
||||
"prices": {
|
||||
@@ -1932,6 +1932,27 @@
|
||||
},
|
||||
"tokenizer_id": "openai"
|
||||
},
|
||||
{
|
||||
"id": "12543803-2d5f-4189-addc-821ad71c8b55",
|
||||
"model_name": "gpt-5-2025-08-07",
|
||||
"match_pattern": "(?i)^(gpt-5-2025-08-07)$",
|
||||
"created_at": "2025-08-11T08:00:00.000Z",
|
||||
"updated_at": "2025-08-11T08:00:00.000Z",
|
||||
"prices": {
|
||||
"input": 1.25e-6,
|
||||
"input_cached_tokens": 0.125e-6,
|
||||
"output": 10.0e-6,
|
||||
"input_cache_read": 0.125e-6,
|
||||
"output_reasoning_tokens": 10e-6,
|
||||
"output_reasoning": 10e-6
|
||||
},
|
||||
"tokenizer_config": {
|
||||
"tokensPerName": 1,
|
||||
"tokenizerModel": "gpt-4",
|
||||
"tokensPerMessage": 3
|
||||
},
|
||||
"tokenizer_id": "openai"
|
||||
},
|
||||
{
|
||||
"id": "3d6a975a-a42d-4ea2-a3ec-4ae567d5a364",
|
||||
"model_name": "gpt-5-mini",
|
||||
@@ -1953,6 +1974,27 @@
|
||||
},
|
||||
"tokenizer_id": "openai"
|
||||
},
|
||||
{
|
||||
"id": "03b83894-7172-4e1e-8e8b-37d792484efd",
|
||||
"model_name": "gpt-5-mini-2025-08-07",
|
||||
"match_pattern": "(?i)^(gpt-5-mini-2025-08-07)$",
|
||||
"created_at": "2025-08-11T08:00:00.000Z",
|
||||
"updated_at": "2025-08-11T08:00:00.000Z",
|
||||
"prices": {
|
||||
"input": 0.25e-6,
|
||||
"input_cached_tokens": 0.025e-6,
|
||||
"output": 2.0e-6,
|
||||
"input_cache_read": 0.025e-6,
|
||||
"output_reasoning_tokens": 2e-6,
|
||||
"output_reasoning": 2e-6
|
||||
},
|
||||
"tokenizer_config": {
|
||||
"tokensPerName": 1,
|
||||
"tokenizerModel": "gpt-4",
|
||||
"tokensPerMessage": 3
|
||||
},
|
||||
"tokenizer_id": "openai"
|
||||
},
|
||||
{
|
||||
"id": "f0b40234-b694-4c40-9494-7b0efd860fb9",
|
||||
"model_name": "gpt-5-nano",
|
||||
@@ -1974,6 +2016,27 @@
|
||||
},
|
||||
"tokenizer_id": "openai"
|
||||
},
|
||||
{
|
||||
"id": "4489fde4-a594-4011-948b-526989300cd3",
|
||||
"model_name": "gpt-5-nano-2025-08-07",
|
||||
"match_pattern": "(?i)^(gpt-5-nano-2025-08-07)$",
|
||||
"created_at": "2025-08-11T08:00:00.000Z",
|
||||
"updated_at": "2025-08-11T08:00:00.000Z",
|
||||
"prices": {
|
||||
"input": 0.05e-6,
|
||||
"input_cached_tokens": 0.005e-6,
|
||||
"output": 0.4e-6,
|
||||
"input_cache_read": 0.005e-6,
|
||||
"output_reasoning_tokens": 0.4e-6,
|
||||
"output_reasoning": 0.4e-6
|
||||
},
|
||||
"tokenizer_config": {
|
||||
"tokensPerName": 1,
|
||||
"tokenizerModel": "gpt-4",
|
||||
"tokensPerMessage": 3
|
||||
},
|
||||
"tokenizer_id": "openai"
|
||||
},
|
||||
{
|
||||
"id": "8ba72ee3-ebe8-4110-a614-bf81094447e5",
|
||||
"model_name": "gpt-5-chat-latest",
|
||||
|
||||
@@ -56,22 +56,21 @@ export const handleDataRetentionProcessingJob = async (job: Job) => {
|
||||
);
|
||||
}
|
||||
|
||||
await removeIngestionEventsFromS3AndDeleteClickhouseRefsForProject(
|
||||
projectId,
|
||||
cutoffDate,
|
||||
);
|
||||
|
||||
// Delete ClickHouse (TTL / Delete Queries)
|
||||
logger.info(
|
||||
`[Data Retention] Deleting ClickHouse data older than ${retention} days for project ${projectId}`,
|
||||
`[Data Retention] Deleting ClickHouse and S3 data older than ${retention} days for project ${projectId}`,
|
||||
);
|
||||
await Promise.all([
|
||||
removeIngestionEventsFromS3AndDeleteClickhouseRefsForProject(
|
||||
projectId,
|
||||
cutoffDate,
|
||||
),
|
||||
deleteTracesOlderThanDays(projectId, cutoffDate),
|
||||
deleteObservationsOlderThanDays(projectId, cutoffDate),
|
||||
deleteScoresOlderThanDays(projectId, cutoffDate),
|
||||
]);
|
||||
logger.info(
|
||||
`[Data Retention] Deleted ClickHouse data older than ${retention} days for project ${projectId}`,
|
||||
`[Data Retention] Deleted ClickHouse and S3 data older than ${retention} days for project ${projectId}`,
|
||||
);
|
||||
|
||||
// Set S3 Lifecycle for deletion (Future)
|
||||
|
||||
@@ -262,6 +262,9 @@ const EnvSchema = z.object({
|
||||
.positive()
|
||||
.default(2),
|
||||
LANGFUSE_DELETE_BATCH_SIZE: z.coerce.number().positive().default(2000),
|
||||
LANGFUSE_EXPERIMENT_DATASET_RUN_ITEMS_TRACE_SOURCE_CH: z
|
||||
.enum(["true", "false"])
|
||||
.default("true"),
|
||||
});
|
||||
|
||||
export const env: z.infer<typeof EnvSchema> =
|
||||
|
||||
@@ -135,25 +135,46 @@ export const getDatabaseReadStream = async ({
|
||||
clickhouseConfigs,
|
||||
});
|
||||
|
||||
return scores.map((score) => ({
|
||||
id: score.id,
|
||||
traceId: score.traceId,
|
||||
sessionId: score.sessionId,
|
||||
datasetRunId: score.datasetRunId,
|
||||
timestamp: score.timestamp,
|
||||
source: score.source,
|
||||
name: score.name,
|
||||
dataType: score.dataType,
|
||||
value: score.value,
|
||||
stringValue: score.stringValue,
|
||||
comment: score.comment,
|
||||
metadata: score.metadata,
|
||||
observationId: score.observationId,
|
||||
traceName: score.traceName,
|
||||
userId: score.traceUserId,
|
||||
traceTags: score.traceTags,
|
||||
environment: score.environment,
|
||||
}));
|
||||
// Get author user info for scores
|
||||
// Only users that have valid project write access may write scores
|
||||
// Only users with at least MEMBER permissions (projectMembers:read) may trigger batch exports
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: scores
|
||||
.map((score) => score.authorUserId)
|
||||
.filter((s): s is string => Boolean(s)),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
return scores.map((score) => {
|
||||
const user = users.find((u) => u.id === score.authorUserId);
|
||||
return {
|
||||
id: score.id,
|
||||
traceId: score.traceId,
|
||||
sessionId: score.sessionId,
|
||||
datasetRunId: score.datasetRunId,
|
||||
timestamp: score.timestamp,
|
||||
source: score.source,
|
||||
name: score.name,
|
||||
dataType: score.dataType,
|
||||
value: score.value,
|
||||
stringValue: score.stringValue,
|
||||
comment: score.comment,
|
||||
metadata: score.metadata,
|
||||
observationId: score.observationId,
|
||||
traceName: score.traceName,
|
||||
userId: score.traceUserId,
|
||||
traceTags: score.traceTags,
|
||||
environment: score.environment,
|
||||
authorUserName: user?.name ?? null,
|
||||
};
|
||||
});
|
||||
},
|
||||
env.BATCH_EXPORT_PAGE_SIZE,
|
||||
rowLimit,
|
||||
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
} from "./traceFilterUtils";
|
||||
import {
|
||||
ChatMessageRole,
|
||||
ForbiddenError,
|
||||
LangfuseNotFoundError,
|
||||
Prisma,
|
||||
singleFilter,
|
||||
@@ -490,13 +489,7 @@ export const evaluate = async ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!job?.job_input_trace_id) {
|
||||
throw new ForbiddenError(
|
||||
"Jobs can only be executed on traces and dataset runs for now.",
|
||||
);
|
||||
}
|
||||
|
||||
if (job.status === "CANCELLED") {
|
||||
if (job.status === "CANCELLED" || !job?.job_input_trace_id) {
|
||||
logger.debug(`Job ${job.id} for project ${event.projectId} was cancelled.`);
|
||||
|
||||
await kyselyPrisma.$kysely
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { kyselyPrisma, prisma } from "@langfuse/shared/src/db";
|
||||
import z from "zod/v4";
|
||||
import { createHash } from "crypto";
|
||||
import { env } from "../../env";
|
||||
|
||||
export enum TraceExecutionSource {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
@@ -52,7 +53,9 @@ export enum TraceExecutionSource {
|
||||
*
|
||||
*/
|
||||
export const shouldCreateTrace = (source: TraceExecutionSource) => {
|
||||
// TODO: use env variable instead
|
||||
if (env.LANGFUSE_EXPERIMENT_DATASET_RUN_ITEMS_TRACE_SOURCE_CH === "true") {
|
||||
return source === TraceExecutionSource.CLICKHOUSE;
|
||||
}
|
||||
return source === TraceExecutionSource.POSTGRES;
|
||||
};
|
||||
|
||||
|
||||
@@ -9,21 +9,13 @@ export const processPostgresTraceDelete = async (
|
||||
`Deleting traces ${JSON.stringify(traceIds)} in project ${projectId} from Postgres`,
|
||||
);
|
||||
try {
|
||||
await prisma.jobExecution.updateMany({
|
||||
await prisma.jobExecution.deleteMany({
|
||||
where: {
|
||||
jobInputTraceId: {
|
||||
in: traceIds,
|
||||
},
|
||||
projectId: projectId,
|
||||
},
|
||||
data: {
|
||||
jobInputTraceId: {
|
||||
set: null,
|
||||
},
|
||||
jobInputObservationId: {
|
||||
set: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod/v4";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import defaultModelPrices from "../constants/default-model-prices.json";
|
||||
import { logger } from "@langfuse/shared/src/server";
|
||||
import { clearFullModelCache, logger } from "@langfuse/shared/src/server";
|
||||
|
||||
const DefaultModelPriceSchema = z.object({
|
||||
id: z.string(),
|
||||
@@ -50,6 +50,7 @@ const ExistingModelPriceSchema = z.object({
|
||||
export const upsertDefaultModelPrices = async (force = false) => {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
let hasUpdates = false;
|
||||
logger.debug(`Starting upsert of default model prices (force = ${force})`);
|
||||
|
||||
const parsedDefaultModelPrices = z
|
||||
@@ -129,6 +130,17 @@ export const upsertDefaultModelPrices = async (force = false) => {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
!existingModelUpdateDate &&
|
||||
Object.keys(defaultModelPrice.prices).length === 0
|
||||
) {
|
||||
logger.debug(
|
||||
`No new and existing prices for ${defaultModelPrice.model_name} (${defaultModelPrice.id}). Skipping.`,
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Upsert model and prices in a transaction
|
||||
promises.push(
|
||||
prisma
|
||||
@@ -205,10 +217,19 @@ export const upsertDefaultModelPrices = async (force = false) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (promises.length > 0) {
|
||||
hasUpdates = true;
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
logger.debug(`Completed batch ${i + 1} of ${numBatches}`);
|
||||
}
|
||||
|
||||
if (hasUpdates) {
|
||||
await clearFullModelCache();
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Finished upserting default model prices in ${Date.now() - startTime}ms`,
|
||||
);
|
||||
|
||||
@@ -502,16 +502,16 @@ This checklist documents all references and invocations to the `traces` table gr
|
||||
|
||||
### 4. Aggregation and Analytics Queries
|
||||
|
||||
- [ ] **getTracesCountForPublicApi()** - `web/src/features/public-api/server/traces.ts:299`
|
||||
- [ ] **generateDailyMetrics()** - `web/src/features/public-api/server/dailyMetrics.ts:93`
|
||||
- [ ] **getDailyMetricsCount()** - `web/src/features/public-api/server/dailyMetrics.ts:153`
|
||||
- [x] **getTracesCountForPublicApi()** - `web/src/features/public-api/server/traces.ts:299`
|
||||
- [x] **generateDailyMetrics()** - `web/src/features/public-api/server/dailyMetrics.ts:93`
|
||||
- [x] **getDailyMetricsCount()** - `web/src/features/public-api/server/dailyMetrics.ts:153`
|
||||
- [x] **generateObservationsForPublicApi()** - `web/src/features/public-api/server/observations.ts:80`
|
||||
- [x] **getObservationsCountForPublicApi()** - `web/src/features/public-api/server/observations.ts:108`
|
||||
- [x] **getObservationsTableInternal()** - `packages/shared/src/server/repositories/observations.ts:565`
|
||||
- [x] **_handleGenerateScoresForPublicApi()** - `web/src/features/public-api/server/scores.ts:101`
|
||||
- [x] **_handleGetScoresCountForPublicApi()** - `web/src/features/public-api/server/scores.ts:181`
|
||||
- [x] **getScoresUiGeneric()** - `packages/shared/src/server/repositories/scores.ts:825`
|
||||
- [ ] **getNumericScoreHistogram()** - `packages/shared/src/server/repositories/scores.ts:1074`
|
||||
- [x] **getNumericScoreHistogram()** - `packages/shared/src/server/repositories/scores.ts:1074`
|
||||
- [x] **getTracesGroupedByName()** - `packages/shared/src/server/repositories/traces.ts:489-535`
|
||||
- [x] **getTracesGroupedByUsers()** - `packages/shared/src/server/repositories/traces.ts:537-597`
|
||||
- [x] **getTracesGroupedByTags()** - `packages/shared/src/server/repositories/traces.ts:605-640`
|
||||
@@ -527,10 +527,10 @@ Note: The measureAndReturn utility does not handle query streams well as it prom
|
||||
We need to cover these queries manually and cannot run a comparison.
|
||||
We could use an opt-in on a projectId basis.
|
||||
|
||||
- [ ] **getTracesForPostHog()** - `packages/shared/src/server/repositories/traces.ts:1026-1113`
|
||||
- [ ] **getScoresForPostHog()** - `packages/shared/src/server/repositories/scores.ts:1328`
|
||||
- [ ] **getGenerationsForPosthog()** - `packages/shared/src/server/repositories/observations.ts:1481`
|
||||
- [ ] **getTracesForBlobStorageExport()** - `packages/shared/src/server/repositories/traces.ts:980-1024`
|
||||
- [x] **getTracesForPostHog()** - `packages/shared/src/server/repositories/traces.ts:1026-1113`
|
||||
- [x] **getScoresForPostHog()** - `packages/shared/src/server/repositories/scores.ts:1328`
|
||||
- [x] **getGenerationsForPosthog()** - `packages/shared/src/server/repositories/observations.ts:1481`
|
||||
- [x] **getTracesForBlobStorageExport()** - `packages/shared/src/server/repositories/traces.ts:980-1024`
|
||||
|
||||
### 6. Count and Statistics Queries
|
||||
|
||||
|
||||
Reference in New Issue
Block a user