Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
c427791070 | ||
|
|
25220aef55 | ||
|
|
8c02d5dc22 | ||
|
|
0b60076871 | ||
|
|
b28a83531b | ||
|
|
aa4d34ae66 | ||
|
|
cca352ad13 | ||
|
|
58c96cada7 | ||
|
|
6f93389936 | ||
|
|
b8d9586490 | ||
|
|
534f5696ad | ||
|
|
4cebe2831c | ||
|
|
45a5f3b8a2 | ||
|
|
8dea9b6a3a | ||
|
|
c5b781d3f6 | ||
|
|
07469c928d | ||
|
|
5b10110dfe | ||
|
|
0eb5d1c3c0 |
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "langfuse-development",
|
||||
"forwardPorts": [3000, 5432, 6379, 8123, 9000],
|
||||
"onCreateCommand": "npm install -g pnpm@9.5.0",
|
||||
"postCreateCommand": "curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.3/migrate.linux-amd64.tar.gz | tar xvz && git restore LICENSE README.md && chmod +x migrate && sudo mv migrate /usr/bin && cp .env.dev.example .env && npm install -g @anthropic-ai/claude-code && pnpm i"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# Dev container Dockerfile
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:20-bookworm
|
||||
|
||||
# Install golang-migrate for database migrations
|
||||
RUN curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.3/migrate.linux-amd64.tar.gz | tar xvz && \
|
||||
chmod +x migrate && \
|
||||
mv migrate /usr/local/bin/migrate
|
||||
|
||||
# Install pnpm globally
|
||||
RUN npm install -g pnpm@9.5.0
|
||||
|
||||
# Install Claude Code CLI
|
||||
RUN npm install -g @anthropic-ai/claude-code
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "langfuse-development",
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile"
|
||||
},
|
||||
"forwardPorts": [3000, 5432, 6379, 8123, 9000],
|
||||
"postCreateCommand": "cp .env.dev.example .env && pnpm i"
|
||||
}
|
||||
@@ -76,6 +76,7 @@ REDIS_AUTH="bitnami"
|
||||
REDIS_CLUSTER_ENABLED="true"
|
||||
REDIS_CLUSTER_NODES="127.0.0.1:6370,127.0.0.1:6371,127.0.0.1:6372,127.0.0.1:6373,127.0.0.1:6374,127.0.0.1:6375"
|
||||
LANGFUSE_INGESTION_QUEUE_SHARD_COUNT=8
|
||||
LANGFUSE_TRACE_UPSERT_QUEUE_SHARD_COUNT=4
|
||||
|
||||
# openssl rand -hex 32 used only here
|
||||
ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
|
||||
|
||||
@@ -57,8 +57,6 @@ yarn-error.log*
|
||||
|
||||
|
||||
# vscode
|
||||
.devcontainer
|
||||
|
||||
node_modules
|
||||
**/node_modules
|
||||
**/dist
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
"dependencies": {
|
||||
"@langfuse/shared": "workspace:*",
|
||||
"@opentelemetry/api": ">=1.0.0 <1.10.0",
|
||||
"axios": "^1.8.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"next": "^14.2.30",
|
||||
"next-auth": "^4.24.11",
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse",
|
||||
"version": "3.95.2",
|
||||
"version": "3.97.3",
|
||||
"author": "engineering@langfuse.com",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
@@ -91,7 +91,8 @@
|
||||
"nanoid": "^3.3.8",
|
||||
"katex": "^0.16.21",
|
||||
"tar-fs": "^2.1.2",
|
||||
"rollup@^4.0.0": "^4.22.4"
|
||||
"rollup@^4.0.0": "^4.22.4",
|
||||
"@types/node-fetch": "^2.6.13"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"next-auth@4.24.11": "patches/next-auth@4.24.11.patch"
|
||||
|
||||
@@ -73,10 +73,9 @@
|
||||
"@prisma/client": "^6.10.1",
|
||||
"@react-email/components": "^0.1.0",
|
||||
"@react-email/render": "^1.1.2",
|
||||
"@slack/oauth": "^2.6.0",
|
||||
"@slack/web-api": "^7.0.0",
|
||||
"@slack/oauth": "^3.0.3",
|
||||
"@slack/web-api": "^7.9.3",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"axios": "^1.8.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bullmq": "^5.34.10",
|
||||
"dd-trace": "^5.36.0",
|
||||
|
||||
@@ -150,6 +150,11 @@ export const ActionExecutionStatus = {
|
||||
} as const;
|
||||
export type ActionExecutionStatus =
|
||||
(typeof ActionExecutionStatus)[keyof typeof ActionExecutionStatus];
|
||||
export const SurveyName = {
|
||||
ORG_ONBOARDING: "org_onboarding",
|
||||
USER_ONBOARDING: "user_onboarding",
|
||||
} as const;
|
||||
export type SurveyName = (typeof SurveyName)[keyof typeof SurveyName];
|
||||
export type Account = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
@@ -749,6 +754,15 @@ export type SsoConfig = {
|
||||
auth_provider: string;
|
||||
auth_config: unknown | null;
|
||||
};
|
||||
export type Survey = {
|
||||
id: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
survey_name: SurveyName;
|
||||
response: unknown;
|
||||
user_id: string | null;
|
||||
user_email: string | null;
|
||||
org_id: string | null;
|
||||
};
|
||||
export type TableViewPreset = {
|
||||
id: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
@@ -858,6 +872,7 @@ export type DB = {
|
||||
Session: Session;
|
||||
slack_integrations: SlackIntegration;
|
||||
sso_configs: SsoConfig;
|
||||
surveys: Survey;
|
||||
table_view_presets: TableViewPreset;
|
||||
trace_media: TraceMedia;
|
||||
trace_sessions: TraceSession;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SurveyName" AS ENUM ('org_onboarding', 'user_onboarding');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "surveys" (
|
||||
"id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"survey_name" "SurveyName" NOT NULL,
|
||||
"response" JSONB NOT NULL,
|
||||
"user_id" TEXT,
|
||||
"user_email" TEXT,
|
||||
"org_id" TEXT,
|
||||
|
||||
CONSTRAINT "surveys_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "surveys" ADD CONSTRAINT "surveys_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "surveys" ADD CONSTRAINT "surveys_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- RenameIndex
|
||||
ALTER INDEX "annotation_queue_assignments_project_id_queue_id_key" RENAME TO "annotation_queue_assignments_project_id_queue_id_user_id_key";
|
||||
@@ -89,6 +89,7 @@ model User {
|
||||
tableViewPresetCreated TableViewPreset[] @relation("CreatedByUser")
|
||||
tableViewPresetUpdated TableViewPreset[] @relation("UpdatedByUser")
|
||||
annotationQueueAssignment AnnotationQueueAssignment[]
|
||||
surveys Survey[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -113,6 +114,7 @@ model Organization {
|
||||
projects Project[]
|
||||
MembershipInvitation MembershipInvitation[]
|
||||
ApiKey ApiKey[]
|
||||
surveys Survey[]
|
||||
|
||||
@@map("organizations")
|
||||
}
|
||||
@@ -1397,3 +1399,24 @@ model PendingDeletion {
|
||||
@@index([objectId, object])
|
||||
@@map("pending_deletions")
|
||||
}
|
||||
|
||||
model Survey {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
surveyName SurveyName @map("survey_name")
|
||||
response Json
|
||||
userId String? @map("user_id")
|
||||
userEmail String? @map("user_email")
|
||||
orgId String? @map("org_id")
|
||||
org Organization? @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("surveys")
|
||||
}
|
||||
|
||||
enum SurveyName {
|
||||
ORG_ONBOARDING @map("org_onboarding")
|
||||
USER_ONBOARDING @map("user_onboarding")
|
||||
|
||||
@@map("SurveyName")
|
||||
}
|
||||
|
||||
@@ -50,6 +50,10 @@ const EnvSchema = z.object({
|
||||
.nonnegative()
|
||||
.default(15_000),
|
||||
LANGFUSE_INGESTION_QUEUE_SHARD_COUNT: z.coerce.number().positive().default(1),
|
||||
LANGFUSE_TRACE_UPSERT_QUEUE_SHARD_COUNT: z.coerce
|
||||
.number()
|
||||
.positive()
|
||||
.default(1),
|
||||
LANGFUSE_TRACE_DELETE_DELAY_MS: z.coerce
|
||||
.number()
|
||||
.nonnegative()
|
||||
@@ -154,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()
|
||||
@@ -186,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;
|
||||
|
||||
@@ -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;
|
||||
}): Promise<void> => {
|
||||
(
|
||||
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",
|
||||
|
||||
@@ -6,7 +6,6 @@ import { DatasetRunItemUpsertQueue } from "./datasetRunItemUpsert";
|
||||
import { EvalExecutionQueue } from "./evalExecutionQueue";
|
||||
import { ExperimentCreateQueue } from "./experimentCreateQueue";
|
||||
import { SecondaryIngestionQueue } from "./ingestionQueue";
|
||||
import { TraceUpsertQueue } from "./traceUpsert";
|
||||
import { TraceDeleteQueue } from "./traceDelete";
|
||||
import { ProjectDeleteQueue } from "./projectDelete";
|
||||
import { PostHogIntegrationQueue } from "./postHogIntegrationQueue";
|
||||
@@ -25,10 +24,13 @@ import { WebhookQueue } from "./webhookQueue";
|
||||
import { EntityChangeQueue } from "./entityChangeQueue";
|
||||
import { DatasetDeleteQueue } from "./datasetDelete";
|
||||
|
||||
// IngestionQueue is sharded and requires a sharding key
|
||||
// Use IngestionQueue.getInstance({ shardName: queueName }) directly instead
|
||||
// IngestionQueue and TraceUpsert are sharded and require a sharding key
|
||||
// Use IngestionQueue.getInstance({ shardName: queueName }) or TraceUpsertQueue.getInstance({ shardName: queueName }) directly instead
|
||||
export function getQueue(
|
||||
queueName: Exclude<QueueName, QueueName.IngestionQueue>,
|
||||
queueName: Exclude<
|
||||
QueueName,
|
||||
QueueName.IngestionQueue | QueueName.TraceUpsert
|
||||
>,
|
||||
): Queue | null {
|
||||
switch (queueName) {
|
||||
case QueueName.BatchExport:
|
||||
@@ -43,8 +45,6 @@ export function getQueue(
|
||||
return EvalExecutionQueue.getInstance();
|
||||
case QueueName.ExperimentCreate:
|
||||
return ExperimentCreateQueue.getInstance();
|
||||
case QueueName.TraceUpsert:
|
||||
return TraceUpsertQueue.getInstance();
|
||||
case QueueName.TraceDelete:
|
||||
return TraceDeleteQueue.getInstance();
|
||||
case QueueName.ProjectDelete:
|
||||
|
||||
@@ -6,45 +6,92 @@ import {
|
||||
getQueuePrefix,
|
||||
} from "./redis";
|
||||
import { logger } from "../logger";
|
||||
import { getShardIndex } from "./sharding";
|
||||
import { env } from "../../env";
|
||||
|
||||
export class TraceUpsertQueue {
|
||||
private static instance: Queue<TQueueJobTypes[QueueName.TraceUpsert]> | null =
|
||||
null;
|
||||
private static instances: Map<
|
||||
number,
|
||||
Queue<TQueueJobTypes[QueueName.TraceUpsert]> | null
|
||||
> = new Map();
|
||||
|
||||
public static getInstance(): Queue<
|
||||
TQueueJobTypes[QueueName.TraceUpsert]
|
||||
> | null {
|
||||
if (TraceUpsertQueue.instance) return TraceUpsertQueue.instance;
|
||||
public static getShardNames() {
|
||||
return Array.from(
|
||||
{ length: env.LANGFUSE_TRACE_UPSERT_QUEUE_SHARD_COUNT },
|
||||
(_, i) => `${QueueName.TraceUpsert}${i > 0 ? `-${i}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
static getShardIndexFromShardName(
|
||||
shardName: string | undefined,
|
||||
): number | null {
|
||||
if (!shardName) return null;
|
||||
|
||||
// Extract shard index from shard name
|
||||
const shardIndex =
|
||||
shardName === QueueName.TraceUpsert
|
||||
? 0
|
||||
: parseInt(shardName.replace(`${QueueName.TraceUpsert}-`, ""), 10);
|
||||
|
||||
if (isNaN(shardIndex)) return null;
|
||||
return shardIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the trace upsert queue instance for the given sharding key or shard name.
|
||||
* @param shardingKey - ShardingKey is being hashed and randomly allocated to a shard. Should be `projectId-traceId`.
|
||||
* @param shardName - Name of the shard. Should be `trace-upsert-queue-${shardIndex}` or plainly `trace-upsert-queue` for the first shard.
|
||||
*/
|
||||
public static getInstance({
|
||||
shardingKey,
|
||||
shardName,
|
||||
}: {
|
||||
shardingKey?: string;
|
||||
shardName?: string;
|
||||
} = {}): Queue<TQueueJobTypes[QueueName.TraceUpsert]> | null {
|
||||
const shardIndex =
|
||||
TraceUpsertQueue.getShardIndexFromShardName(shardName) ??
|
||||
(env.REDIS_CLUSTER_ENABLED === "true" && shardingKey
|
||||
? getShardIndex(
|
||||
shardingKey,
|
||||
env.LANGFUSE_TRACE_UPSERT_QUEUE_SHARD_COUNT,
|
||||
)
|
||||
: 0);
|
||||
|
||||
// Check if we already have an instance for this shard
|
||||
if (TraceUpsertQueue.instances.has(shardIndex)) {
|
||||
return TraceUpsertQueue.instances.get(shardIndex) || null;
|
||||
}
|
||||
|
||||
const newRedis = createNewRedisInstance({
|
||||
enableOfflineQueue: false,
|
||||
...redisQueueRetryOptions,
|
||||
});
|
||||
|
||||
TraceUpsertQueue.instance = newRedis
|
||||
? new Queue<TQueueJobTypes[QueueName.TraceUpsert]>(
|
||||
QueueName.TraceUpsert,
|
||||
{
|
||||
connection: newRedis,
|
||||
prefix: getQueuePrefix(QueueName.TraceUpsert),
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: 100, // Important: If not true, new jobs for that ID would be ignored as jobs in the complete set are still considered as part of the queue
|
||||
removeOnFail: 100_000,
|
||||
attempts: 5,
|
||||
delay: 15_000, // 15 seconds
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 5000,
|
||||
},
|
||||
const name = `${QueueName.TraceUpsert}${shardIndex > 0 ? `-${shardIndex}` : ""}`;
|
||||
const queueInstance = newRedis
|
||||
? new Queue<TQueueJobTypes[QueueName.TraceUpsert]>(name, {
|
||||
connection: newRedis,
|
||||
prefix: getQueuePrefix(name),
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: 100,
|
||||
removeOnFail: 100_000,
|
||||
attempts: 5,
|
||||
delay: 15_000, // 15 seconds
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 5000,
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
: null;
|
||||
|
||||
TraceUpsertQueue.instance?.on("error", (err) => {
|
||||
logger.error("TraceUpsertQueue error", err);
|
||||
queueInstance?.on("error", (err) => {
|
||||
logger.error(`TraceUpsertQueue shard ${shardIndex} error`, err);
|
||||
});
|
||||
|
||||
return TraceUpsertQueue.instance;
|
||||
TraceUpsertQueue.instances.set(shardIndex, queueInstance);
|
||||
|
||||
return queueInstance;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1184,19 +1197,6 @@ export const deleteTraces = async (projectId: string, traceIds: string[]) => {
|
||||
},
|
||||
tags: input.tags,
|
||||
}),
|
||||
// Delete from traces_null
|
||||
commandClickhouse({
|
||||
query: `
|
||||
DELETE FROM traces_null
|
||||
WHERE project_id = {projectId: String}
|
||||
AND id IN ({traceIds: Array(String)});
|
||||
`,
|
||||
params: input.params,
|
||||
clickhouseConfigs: {
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
|
||||
},
|
||||
tags: input.tags,
|
||||
}),
|
||||
// Delete from traces_all_amt
|
||||
commandClickhouse({
|
||||
query: `
|
||||
@@ -1377,18 +1377,6 @@ export const deleteTracesByProjectId = async (projectId: string) => {
|
||||
},
|
||||
tags: input.tags,
|
||||
}),
|
||||
// Delete from traces_null
|
||||
commandClickhouse({
|
||||
query: `
|
||||
DELETE FROM traces_null
|
||||
WHERE project_id = {projectId: String};
|
||||
`,
|
||||
params: input.params,
|
||||
clickhouseConfigs: {
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
|
||||
},
|
||||
tags: input.tags,
|
||||
}),
|
||||
// Delete from traces_all_amt
|
||||
commandClickhouse({
|
||||
query: `
|
||||
@@ -1761,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,
|
||||
@@ -1773,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)}
|
||||
@@ -1796,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,
|
||||
@@ -1808,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,
|
||||
@@ -1835,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)}
|
||||
@@ -1854,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,
|
||||
|
||||
@@ -35,6 +35,12 @@ export const HistogramChartConfig = BaseTotalValueChartConfig.extend({
|
||||
|
||||
export const PivotTableChartConfig = BaseTotalValueChartConfig.extend({
|
||||
type: z.literal("PIVOT_TABLE"),
|
||||
defaultSort: z
|
||||
.object({
|
||||
column: z.string(),
|
||||
order: z.enum(["ASC", "DESC"]),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// Define dimension schema
|
||||
|
||||
+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",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
Generated
+88
-147
@@ -9,6 +9,7 @@ overrides:
|
||||
katex: ^0.16.21
|
||||
tar-fs: ^2.1.2
|
||||
rollup@^4.0.0: ^4.22.4
|
||||
'@types/node-fetch': ^2.6.13
|
||||
|
||||
patchedDependencies:
|
||||
next-auth@4.24.11:
|
||||
@@ -49,9 +50,6 @@ importers:
|
||||
'@opentelemetry/api':
|
||||
specifier: '>=1.0.0 <1.10.0'
|
||||
version: 1.9.0
|
||||
axios:
|
||||
specifier: ^1.8.2
|
||||
version: 1.8.2
|
||||
https-proxy-agent:
|
||||
specifier: ^7.0.6
|
||||
version: 7.0.6
|
||||
@@ -188,17 +186,14 @@ importers:
|
||||
specifier: ^1.1.2
|
||||
version: 1.1.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@slack/oauth':
|
||||
specifier: ^2.6.0
|
||||
version: 2.6.3
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.3
|
||||
'@slack/web-api':
|
||||
specifier: ^7.0.0
|
||||
specifier: ^7.9.3
|
||||
version: 7.9.3
|
||||
'@types/bcryptjs':
|
||||
specifier: ^2.4.6
|
||||
version: 2.4.6
|
||||
axios:
|
||||
specifier: ^1.8.2
|
||||
version: 1.8.2
|
||||
bcryptjs:
|
||||
specifier: ^2.4.3
|
||||
version: 2.4.3
|
||||
@@ -228,10 +223,10 @@ importers:
|
||||
version: 0.27.4
|
||||
langchain:
|
||||
specifier: ^0.3.28
|
||||
version: 0.3.28(@langchain/anthropic@0.3.22(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/aws@0.1.11(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(@langchain/google-genai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/google-vertexai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(zod@3.25.62))(axios@1.8.2)(cheerio@1.0.0)(handlebars@4.7.8)(openai@4.104.0(zod@3.25.62))
|
||||
version: 0.3.28(@langchain/anthropic@0.3.22(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/aws@0.1.11(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(@langchain/google-genai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/google-vertexai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(zod@3.25.62))(axios@1.11.0)(cheerio@1.0.0)(handlebars@4.7.8)(openai@4.104.0(zod@3.25.62))
|
||||
langfuse-langchain:
|
||||
specifier: 3.38.4
|
||||
version: 3.38.4(langchain@0.3.28(@langchain/anthropic@0.3.22(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/aws@0.1.11(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(@langchain/google-genai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/google-vertexai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(zod@3.25.62))(axios@1.8.2)(cheerio@1.0.0)(handlebars@4.7.8)(openai@4.104.0(zod@3.25.62)))
|
||||
version: 3.38.4(langchain@0.3.28(@langchain/anthropic@0.3.22(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/aws@0.1.11(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(@langchain/google-genai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/google-vertexai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(zod@3.25.62))(axios@1.11.0)(cheerio@1.0.0)(handlebars@4.7.8)(openai@4.104.0(zod@3.25.62)))
|
||||
lodash:
|
||||
specifier: ^4.17.21
|
||||
version: 4.17.21
|
||||
@@ -518,12 +513,6 @@ importers:
|
||||
'@sentry/nextjs':
|
||||
specifier: ^8.52.0
|
||||
version: 8.55.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.26.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.53.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.26.0(@opentelemetry/api@1.9.0))(next@14.2.30(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)(webpack@5.97.1)
|
||||
'@slack/oauth':
|
||||
specifier: ^2.6.0
|
||||
version: 2.6.3
|
||||
'@slack/web-api':
|
||||
specifier: ^7.0.0
|
||||
version: 7.9.3
|
||||
'@t3-oss/env-nextjs':
|
||||
specifier: ^0.11.1
|
||||
version: 0.11.1(typescript@5.4.5)(zod@3.25.62)
|
||||
@@ -912,9 +901,6 @@ importers:
|
||||
'@prisma/instrumentation':
|
||||
specifier: ^6.3.0
|
||||
version: 6.3.0(@opentelemetry/api@1.9.0)
|
||||
'@slack/web-api':
|
||||
specifier: ^7.0.0
|
||||
version: 7.9.3
|
||||
backoff:
|
||||
specifier: ^2.5.0
|
||||
version: 2.5.0
|
||||
@@ -2304,6 +2290,9 @@ packages:
|
||||
'@floating-ui/core@1.7.2':
|
||||
resolution: {integrity: sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==}
|
||||
|
||||
'@floating-ui/core@1.7.3':
|
||||
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
|
||||
|
||||
'@floating-ui/dom@1.6.11':
|
||||
resolution: {integrity: sha512-qkMCxSR24v2vGkhYDo/UzxfJN3D4syqSjyuTFz6C7XcpU1pASPRieNI0Kj5VP3/503mOfYiGY891ugBX1GlABQ==}
|
||||
|
||||
@@ -2313,6 +2302,9 @@ packages:
|
||||
'@floating-ui/dom@1.7.2':
|
||||
resolution: {integrity: sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==}
|
||||
|
||||
'@floating-ui/dom@1.7.3':
|
||||
resolution: {integrity: sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag==}
|
||||
|
||||
'@floating-ui/react-dom@1.3.0':
|
||||
resolution: {integrity: sha512-htwHm67Ji5E/pROEAr7f8IKFShuiCKHwUC/UY4vC3I5jiSvGFAYnSYiZO5MlGmads+QqvUkR9ANHEguGrDv72g==}
|
||||
peerDependencies:
|
||||
@@ -2331,6 +2323,12 @@ packages:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
'@floating-ui/react-dom@2.1.5':
|
||||
resolution: {integrity: sha512-HDO/1/1oH9fjj4eLgegrlH3dklZpHtUYYFiVwMUwfGvk9jWDRWqkklA2/NFScknrcNSspbV868WjXORvreDX+Q==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
'@floating-ui/react@0.19.2':
|
||||
resolution: {integrity: sha512-JyNk4A0Ezirq8FlXECvRtQOX/iBe5Ize0W/pLkrZjfHW9GUV7Xnq6zm6fyZuQzaHHqEnVizmvlA96e1/CkZv+w==}
|
||||
peerDependencies:
|
||||
@@ -2937,8 +2935,8 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@mui/types@7.4.4':
|
||||
resolution: {integrity: sha512-p63yhbX52MO/ajXC7hDHJA5yjzJekvWD3q4YDLl1rSg+OXLczMYPvTuSuviPRCgRX8+E42RXz1D/dz9SxPSlWg==}
|
||||
'@mui/types@7.4.5':
|
||||
resolution: {integrity: sha512-ZPwlAOE3e8C0piCKbaabwrqZbW4QvWz0uapVPWya7fYj6PeDkl5sSJmomT7wjOcZGPB48G/a6Ubidqreptxz4g==}
|
||||
peerDependencies:
|
||||
'@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
@@ -4697,26 +4695,18 @@ packages:
|
||||
'@sinonjs/fake-timers@10.3.0':
|
||||
resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==}
|
||||
|
||||
'@slack/logger@3.0.0':
|
||||
resolution: {integrity: sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA==}
|
||||
engines: {node: '>= 12.13.0', npm: '>= 6.12.0'}
|
||||
|
||||
'@slack/logger@4.0.0':
|
||||
resolution: {integrity: sha512-Wz7QYfPAlG/DR+DfABddUZeNgoeY7d1J39OCR2jR+v7VBsB8ezulDK5szTnDDPDwLH5IWhLvXIHlCFZV7MSKgA==}
|
||||
engines: {node: '>= 18', npm: '>= 8.6.0'}
|
||||
|
||||
'@slack/oauth@2.6.3':
|
||||
resolution: {integrity: sha512-1amXs6xRkJpoH6zSgjVPgGEJXCibKNff9WNDijcejIuVy1HFAl1adh7lehaGNiHhTWfQkfKxBiF+BGn56kvoFw==}
|
||||
engines: {node: '>=12.13.0', npm: '>=6.12.0'}
|
||||
'@slack/oauth@3.0.3':
|
||||
resolution: {integrity: sha512-N3pLJPacZ57bqmD1HzHDmHe/CNsL9pESZXRw7pfv6QXJVRgufPIW84aRpAez2Xb0616RpGBYZW5dZH0Nbskwyg==}
|
||||
engines: {node: '>=18', npm: '>=8.6.0'}
|
||||
|
||||
'@slack/types@2.15.0':
|
||||
resolution: {integrity: sha512-livb1gyG3J8ATLBJ3KjZfjHpTRz9btY1m5cgNuXxWJbhwRB1Gwb8Ly6XLJm2Sy1W6h+vLgqIHg7IwKrF1C1Szg==}
|
||||
engines: {node: '>= 12.13.0', npm: '>= 6.12.0'}
|
||||
|
||||
'@slack/web-api@6.13.0':
|
||||
resolution: {integrity: sha512-dv65crIgdh9ZYHrevLU6XFHTQwTyDmNqEqzuIrV+Vqe/vgiG6w37oex5ePDU1RGm2IJ90H8iOvHFvzdEO/vB+g==}
|
||||
engines: {node: '>= 12.13.0', npm: '>= 6.12.0'}
|
||||
|
||||
'@slack/web-api@7.9.3':
|
||||
resolution: {integrity: sha512-xjnoldVJyoUe61Ltqjr2UVYBolcsTpp5ottqzSI3l41UCaJgHSHIOpGuYps+nhLFvDfGGpDGUeQ7BWiq3+ypqA==}
|
||||
engines: {node: '>= 18', npm: '>= 8.6.0'}
|
||||
@@ -5400,9 +5390,6 @@ packages:
|
||||
'@types/http-errors@2.0.5':
|
||||
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
|
||||
|
||||
'@types/is-stream@1.1.0':
|
||||
resolution: {integrity: sha512-jkZatu4QVbR60mpIzjINmtS1ZF4a/FqdTUTBeQDVOQ2PYyidtwFKr0B5G6ERukKwliq+7mIXvxyppwzG5EgRYg==}
|
||||
|
||||
'@types/istanbul-lib-coverage@2.0.6':
|
||||
resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==}
|
||||
|
||||
@@ -5424,8 +5411,8 @@ packages:
|
||||
'@types/json5@0.0.29':
|
||||
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
|
||||
|
||||
'@types/jsonwebtoken@8.5.9':
|
||||
resolution: {integrity: sha512-272FMnFGzAVMGtu9tkr29hRL6bZj4Zs1KZNeHLnKqAvp06tAIcarTMwOh8/8bz4FmKRcMxZhZNeUAQsNLoiPhg==}
|
||||
'@types/jsonwebtoken@9.0.10':
|
||||
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
|
||||
|
||||
'@types/katex@0.16.7':
|
||||
resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==}
|
||||
@@ -5439,14 +5426,14 @@ packages:
|
||||
'@types/mime@1.3.5':
|
||||
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
|
||||
|
||||
'@types/ms@0.7.34':
|
||||
resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==}
|
||||
'@types/ms@2.1.0':
|
||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||
|
||||
'@types/mysql@2.15.26':
|
||||
resolution: {integrity: sha512-DSLCOXhkvfS5WNNPbfn2KdICAmk8lLc+/PNvnPnF7gOdMZCxopXduqv0OQ13y/yA/zXTSikZZqVgybUxOEg6YQ==}
|
||||
|
||||
'@types/node-fetch@2.6.12':
|
||||
resolution: {integrity: sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==}
|
||||
'@types/node-fetch@2.6.13':
|
||||
resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==}
|
||||
|
||||
'@types/node@18.19.120':
|
||||
resolution: {integrity: sha512-WtCGHFXnVI8WHLxDAt5TbnCM4eSE+nI0QN2NJtwzcgMhht2eNz6V9evJrk+lwC8bCY8OWV5Ym8Jz7ZEyGnKnMA==}
|
||||
@@ -6352,8 +6339,8 @@ packages:
|
||||
caniuse-lite@1.0.30001723:
|
||||
resolution: {integrity: sha512-1R/elMjtehrFejxwmexeXAtae5UO9iSyFn6G/I806CYC/BLyyBk1EPhrKBkWhy6wM6Xnm47dSJQec+tLJ39WHw==}
|
||||
|
||||
caniuse-lite@1.0.30001727:
|
||||
resolution: {integrity: sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==}
|
||||
caniuse-lite@1.0.30001733:
|
||||
resolution: {integrity: sha512-e4QKw/O2Kavj2VQTKZWrwzkt3IxOmIlU6ajRb6LP64LHpBo1J67k2Hi4Vu/TgJWsNtynurfS0uK3MaUTCPfu5Q==}
|
||||
|
||||
ccount@2.0.1:
|
||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
||||
@@ -7245,8 +7232,8 @@ packages:
|
||||
electron-to-chromium@1.5.167:
|
||||
resolution: {integrity: sha512-LxcRvnYO5ez2bMOFpbuuVuAI5QNeY1ncVytE/KXaL6ZNfzX1yPlAO0nSOyIHx2fVAuUprMqPs/TdVhUFZy7SIQ==}
|
||||
|
||||
electron-to-chromium@1.5.192:
|
||||
resolution: {integrity: sha512-rP8Ez0w7UNw/9j5eSXCe10o1g/8B1P5SM90PCCMVkIRQn2R0LEHWz4Eh9RnxkniuDe1W0cTSOB3MLlkTGDcuCg==}
|
||||
electron-to-chromium@1.5.199:
|
||||
resolution: {integrity: sha512-3gl0S7zQd88kCAZRO/DnxtBKuhMO4h0EaQIN3YgZfV6+pW+5+bf2AdQeHNESCoaQqo/gjGVYEf2YM4O5HJQqpQ==}
|
||||
|
||||
electron-to-chromium@1.5.33:
|
||||
resolution: {integrity: sha512-+cYTcFB1QqD4j4LegwLfpCNxifb6dDFUAwk6RsLusCwIaZI6or2f+q8rs5tTB2YC53HhOlIbEaqHMAAC8IOIwA==}
|
||||
@@ -7281,8 +7268,8 @@ packages:
|
||||
resolution: {integrity: sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
enhanced-resolve@5.18.2:
|
||||
resolution: {integrity: sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==}
|
||||
enhanced-resolve@5.18.3:
|
||||
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
entities@4.5.0:
|
||||
@@ -7651,9 +7638,6 @@ packages:
|
||||
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
eventemitter3@3.1.2:
|
||||
resolution: {integrity: sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==}
|
||||
|
||||
eventemitter3@4.0.7:
|
||||
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
|
||||
|
||||
@@ -8515,10 +8499,6 @@ packages:
|
||||
is-ssh@1.4.1:
|
||||
resolution: {integrity: sha512-JNeu1wQsHjyHgn9NcWTaXq6zWSR6hqE0++zhfZlkFBbScNkyvxCdeV8sRkSBaeLKxmbpR21brail63ACNxJ0Tg==}
|
||||
|
||||
is-stream@1.1.0:
|
||||
resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-stream@2.0.1:
|
||||
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -10479,7 +10459,7 @@ packages:
|
||||
|
||||
puppeteer@19.11.1:
|
||||
resolution: {integrity: sha512-39olGaX2djYUdhaQQHDZ0T0GwEp+5f9UB9HmEP0qHfdQHIq0xGQZuAZ5TLnJIc/88SrPLpEflPC+xUqOTv3c5g==}
|
||||
deprecated: < 22.8.2 is no longer supported
|
||||
deprecated: < 24.9.0 is no longer supported
|
||||
|
||||
pure-rand@6.0.4:
|
||||
resolution: {integrity: sha512-LA0Y9kxMYv47GIPJy6MI84fqTd2HmYZI83W/kM/SkKfDlajnZYfmXFTxkbY+xSBPkLJxltMa9hIkmdc29eguMA==}
|
||||
@@ -12376,7 +12356,7 @@ snapshots:
|
||||
'@anthropic-ai/sdk@0.39.0':
|
||||
dependencies:
|
||||
'@types/node': 18.19.120
|
||||
'@types/node-fetch': 2.6.12
|
||||
'@types/node-fetch': 2.6.13
|
||||
abort-controller: 3.0.0
|
||||
agentkeepalive: 4.6.0
|
||||
form-data-encoder: 1.7.2
|
||||
@@ -14333,6 +14313,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@floating-ui/utils': 0.2.10
|
||||
|
||||
'@floating-ui/core@1.7.3':
|
||||
dependencies:
|
||||
'@floating-ui/utils': 0.2.10
|
||||
|
||||
'@floating-ui/dom@1.6.11':
|
||||
dependencies:
|
||||
'@floating-ui/core': 1.6.8
|
||||
@@ -14348,6 +14332,11 @@ snapshots:
|
||||
'@floating-ui/core': 1.7.2
|
||||
'@floating-ui/utils': 0.2.10
|
||||
|
||||
'@floating-ui/dom@1.7.3':
|
||||
dependencies:
|
||||
'@floating-ui/core': 1.7.3
|
||||
'@floating-ui/utils': 0.2.10
|
||||
|
||||
'@floating-ui/react-dom@1.3.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.6.3
|
||||
@@ -14366,6 +14355,12 @@ snapshots:
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
|
||||
'@floating-ui/react-dom@2.1.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.3
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
|
||||
'@floating-ui/react@0.19.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
'@floating-ui/react-dom': 1.3.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
@@ -14665,7 +14660,7 @@ snapshots:
|
||||
'@jest/console@29.7.0':
|
||||
dependencies:
|
||||
'@jest/types': 29.6.3
|
||||
'@types/node': 20.11.29
|
||||
'@types/node': 20.14.8
|
||||
chalk: 4.1.2
|
||||
jest-message-util: 29.7.0
|
||||
jest-util: 29.7.0
|
||||
@@ -14750,7 +14745,7 @@ snapshots:
|
||||
'@jest/transform': 29.7.0
|
||||
'@jest/types': 29.6.3
|
||||
'@jridgewell/trace-mapping': 0.3.25
|
||||
'@types/node': 20.11.29
|
||||
'@types/node': 20.14.8
|
||||
chalk: 4.1.2
|
||||
collect-v8-coverage: 1.0.2
|
||||
exit: 0.1.2
|
||||
@@ -15046,8 +15041,8 @@ snapshots:
|
||||
'@mui/base@5.0.0-beta.40(@types/react@18.2.79)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.2
|
||||
'@floating-ui/react-dom': 2.1.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@mui/types': 7.4.4(@types/react@18.2.79)
|
||||
'@floating-ui/react-dom': 2.1.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@mui/types': 7.4.5(@types/react@18.2.79)
|
||||
'@mui/utils': 5.17.1(@types/react@18.2.79)(react@18.2.0)
|
||||
'@popperjs/core': 2.11.8
|
||||
clsx: 2.1.1
|
||||
@@ -15065,7 +15060,7 @@ snapshots:
|
||||
'@mui/base': 5.0.0-beta.40(@types/react@18.2.79)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@mui/core-downloads-tracker': 5.18.0
|
||||
'@mui/system': 5.16.7(@emotion/react@11.11.4(@types/react@18.2.79)(react@18.2.0))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.2.79)(react@18.2.0))(@types/react@18.2.79)(react@18.2.0))(@types/react@18.2.79)(react@18.2.0)
|
||||
'@mui/types': 7.4.4(@types/react@18.2.79)
|
||||
'@mui/types': 7.4.5(@types/react@18.2.79)
|
||||
'@mui/utils': 5.17.1(@types/react@18.2.79)(react@18.2.0)
|
||||
'@types/react-transition-group': 4.4.12(@types/react@18.2.79)
|
||||
clsx: 2.1.1
|
||||
@@ -15106,7 +15101,7 @@ snapshots:
|
||||
'@babel/runtime': 7.28.2
|
||||
'@mui/private-theming': 5.17.1(@types/react@18.2.79)(react@18.2.0)
|
||||
'@mui/styled-engine': 5.18.0(@emotion/react@11.11.4(@types/react@18.2.79)(react@18.2.0))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.2.79)(react@18.2.0))(@types/react@18.2.79)(react@18.2.0))(react@18.2.0)
|
||||
'@mui/types': 7.4.4(@types/react@18.2.79)
|
||||
'@mui/types': 7.4.5(@types/react@18.2.79)
|
||||
'@mui/utils': 5.17.1(@types/react@18.2.79)(react@18.2.0)
|
||||
clsx: 2.1.1
|
||||
csstype: 3.1.3
|
||||
@@ -15125,7 +15120,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 18.2.79
|
||||
|
||||
'@mui/types@7.4.4(@types/react@18.2.79)':
|
||||
'@mui/types@7.4.5(@types/react@18.2.79)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.2
|
||||
optionalDependencies:
|
||||
@@ -17155,19 +17150,15 @@ snapshots:
|
||||
dependencies:
|
||||
'@sinonjs/commons': 3.0.1
|
||||
|
||||
'@slack/logger@3.0.0':
|
||||
dependencies:
|
||||
'@types/node': 20.11.29
|
||||
|
||||
'@slack/logger@4.0.0':
|
||||
dependencies:
|
||||
'@types/node': 20.11.29
|
||||
|
||||
'@slack/oauth@2.6.3':
|
||||
'@slack/oauth@3.0.3':
|
||||
dependencies:
|
||||
'@slack/logger': 3.0.0
|
||||
'@slack/web-api': 6.13.0
|
||||
'@types/jsonwebtoken': 8.5.9
|
||||
'@slack/logger': 4.0.0
|
||||
'@slack/web-api': 7.9.3
|
||||
'@types/jsonwebtoken': 9.0.10
|
||||
'@types/node': 20.11.29
|
||||
jsonwebtoken: 9.0.2
|
||||
lodash.isstring: 4.0.1
|
||||
@@ -17176,22 +17167,6 @@ snapshots:
|
||||
|
||||
'@slack/types@2.15.0': {}
|
||||
|
||||
'@slack/web-api@6.13.0':
|
||||
dependencies:
|
||||
'@slack/logger': 3.0.0
|
||||
'@slack/types': 2.15.0
|
||||
'@types/is-stream': 1.1.0
|
||||
'@types/node': 20.11.29
|
||||
axios: 1.8.2
|
||||
eventemitter3: 3.1.2
|
||||
form-data: 2.5.3
|
||||
is-electron: 2.2.2
|
||||
is-stream: 1.1.0
|
||||
p-queue: 6.6.2
|
||||
p-retry: 4.6.2
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
'@slack/web-api@7.9.3':
|
||||
dependencies:
|
||||
'@slack/logger': 4.0.0
|
||||
@@ -18075,7 +18050,7 @@ snapshots:
|
||||
|
||||
'@types/debug@4.1.8':
|
||||
dependencies:
|
||||
'@types/ms': 0.7.34
|
||||
'@types/ms': 2.1.0
|
||||
|
||||
'@types/deep-eql@4.0.2': {}
|
||||
|
||||
@@ -18114,7 +18089,7 @@ snapshots:
|
||||
|
||||
'@types/express-serve-static-core@4.19.6':
|
||||
dependencies:
|
||||
'@types/node': 20.11.29
|
||||
'@types/node': 20.14.8
|
||||
'@types/qs': 6.14.0
|
||||
'@types/range-parser': 1.2.7
|
||||
'@types/send': 0.17.5
|
||||
@@ -18141,7 +18116,7 @@ snapshots:
|
||||
|
||||
'@types/graceful-fs@4.1.9':
|
||||
dependencies:
|
||||
'@types/node': 20.11.29
|
||||
'@types/node': 20.14.8
|
||||
|
||||
'@types/hammerjs@2.0.46': {}
|
||||
|
||||
@@ -18151,10 +18126,6 @@ snapshots:
|
||||
|
||||
'@types/http-errors@2.0.5': {}
|
||||
|
||||
'@types/is-stream@1.1.0':
|
||||
dependencies:
|
||||
'@types/node': 20.11.29
|
||||
|
||||
'@types/istanbul-lib-coverage@2.0.6': {}
|
||||
|
||||
'@types/istanbul-lib-report@3.0.3':
|
||||
@@ -18180,8 +18151,9 @@ snapshots:
|
||||
|
||||
'@types/json5@0.0.29': {}
|
||||
|
||||
'@types/jsonwebtoken@8.5.9':
|
||||
'@types/jsonwebtoken@9.0.10':
|
||||
dependencies:
|
||||
'@types/ms': 2.1.0
|
||||
'@types/node': 20.11.29
|
||||
|
||||
'@types/katex@0.16.7': {}
|
||||
@@ -18194,16 +18166,16 @@ snapshots:
|
||||
|
||||
'@types/mime@1.3.5': {}
|
||||
|
||||
'@types/ms@0.7.34': {}
|
||||
'@types/ms@2.1.0': {}
|
||||
|
||||
'@types/mysql@2.15.26':
|
||||
dependencies:
|
||||
'@types/node': 20.11.29
|
||||
|
||||
'@types/node-fetch@2.6.12':
|
||||
'@types/node-fetch@2.6.13':
|
||||
dependencies:
|
||||
'@types/node': 20.11.29
|
||||
form-data: 4.0.2
|
||||
form-data: 4.0.4
|
||||
|
||||
'@types/node@18.19.120':
|
||||
dependencies:
|
||||
@@ -19285,8 +19257,8 @@ snapshots:
|
||||
|
||||
browserslist@4.25.1:
|
||||
dependencies:
|
||||
caniuse-lite: 1.0.30001727
|
||||
electron-to-chromium: 1.5.192
|
||||
caniuse-lite: 1.0.30001733
|
||||
electron-to-chromium: 1.5.199
|
||||
node-releases: 2.0.19
|
||||
update-browserslist-db: 1.1.3(browserslist@4.25.1)
|
||||
|
||||
@@ -19385,7 +19357,7 @@ snapshots:
|
||||
|
||||
caniuse-lite@1.0.30001723: {}
|
||||
|
||||
caniuse-lite@1.0.30001727: {}
|
||||
caniuse-lite@1.0.30001733: {}
|
||||
|
||||
ccount@2.0.1: {}
|
||||
|
||||
@@ -20329,7 +20301,7 @@ snapshots:
|
||||
|
||||
electron-to-chromium@1.5.167: {}
|
||||
|
||||
electron-to-chromium@1.5.192: {}
|
||||
electron-to-chromium@1.5.199: {}
|
||||
|
||||
electron-to-chromium@1.5.33: {}
|
||||
|
||||
@@ -20359,7 +20331,7 @@ snapshots:
|
||||
graceful-fs: 4.2.11
|
||||
tapable: 2.2.1
|
||||
|
||||
enhanced-resolve@5.18.2:
|
||||
enhanced-resolve@5.18.3:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
tapable: 2.2.2
|
||||
@@ -20976,8 +20948,6 @@ snapshots:
|
||||
|
||||
event-target-shim@5.0.1: {}
|
||||
|
||||
eventemitter3@3.1.2: {}
|
||||
|
||||
eventemitter3@4.0.7: {}
|
||||
|
||||
eventemitter3@5.0.1: {}
|
||||
@@ -21942,8 +21912,6 @@ snapshots:
|
||||
dependencies:
|
||||
protocols: 2.0.2
|
||||
|
||||
is-stream@1.1.0: {}
|
||||
|
||||
is-stream@2.0.1: {}
|
||||
|
||||
is-stream@3.0.0: {}
|
||||
@@ -22076,7 +22044,7 @@ snapshots:
|
||||
'@jest/expect': 29.7.0
|
||||
'@jest/test-result': 29.7.0
|
||||
'@jest/types': 29.6.3
|
||||
'@types/node': 20.11.29
|
||||
'@types/node': 20.14.8
|
||||
chalk: 4.1.2
|
||||
co: 4.6.0
|
||||
dedent: 1.5.1(babel-plugin-macros@3.1.0)
|
||||
@@ -22236,7 +22204,7 @@ snapshots:
|
||||
'@jest/environment': 29.7.0
|
||||
'@jest/fake-timers': 29.7.0
|
||||
'@jest/types': 29.6.3
|
||||
'@types/node': 20.11.29
|
||||
'@types/node': 20.14.8
|
||||
jest-mock: 29.7.0
|
||||
jest-util: 29.7.0
|
||||
|
||||
@@ -22246,7 +22214,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@jest/types': 29.6.3
|
||||
'@types/graceful-fs': 4.1.9
|
||||
'@types/node': 20.11.29
|
||||
'@types/node': 20.14.8
|
||||
anymatch: 3.1.3
|
||||
fb-watchman: 2.0.2
|
||||
graceful-fs: 4.2.11
|
||||
@@ -22320,7 +22288,7 @@ snapshots:
|
||||
'@jest/test-result': 29.7.0
|
||||
'@jest/transform': 29.7.0
|
||||
'@jest/types': 29.6.3
|
||||
'@types/node': 20.11.29
|
||||
'@types/node': 20.14.8
|
||||
chalk: 4.1.2
|
||||
emittery: 0.13.1
|
||||
graceful-fs: 4.2.11
|
||||
@@ -22348,7 +22316,7 @@ snapshots:
|
||||
'@jest/test-result': 29.7.0
|
||||
'@jest/transform': 29.7.0
|
||||
'@jest/types': 29.6.3
|
||||
'@types/node': 20.11.29
|
||||
'@types/node': 20.14.8
|
||||
chalk: 4.1.2
|
||||
cjs-module-lexer: 1.4.1
|
||||
collect-v8-coverage: 1.0.2
|
||||
@@ -22413,7 +22381,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@jest/test-result': 29.7.0
|
||||
'@jest/types': 29.6.3
|
||||
'@types/node': 20.11.29
|
||||
'@types/node': 20.14.8
|
||||
ansi-escapes: 4.3.2
|
||||
chalk: 4.1.2
|
||||
emittery: 0.13.1
|
||||
@@ -22428,7 +22396,7 @@ snapshots:
|
||||
|
||||
jest-worker@29.7.0:
|
||||
dependencies:
|
||||
'@types/node': 20.11.29
|
||||
'@types/node': 20.14.8
|
||||
jest-util: 29.7.0
|
||||
merge-stream: 2.0.0
|
||||
supports-color: 8.1.1
|
||||
@@ -22674,40 +22642,13 @@ snapshots:
|
||||
- openai
|
||||
- ws
|
||||
|
||||
langchain@0.3.28(@langchain/anthropic@0.3.22(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/aws@0.1.11(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(@langchain/google-genai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/google-vertexai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(zod@3.25.62))(axios@1.8.2)(cheerio@1.0.0)(handlebars@4.7.8)(openai@4.104.0(zod@3.25.62)):
|
||||
dependencies:
|
||||
'@langchain/core': 0.3.58(openai@4.104.0(zod@3.25.62))
|
||||
'@langchain/openai': 0.5.13(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))
|
||||
'@langchain/textsplitters': 0.1.0(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))
|
||||
js-tiktoken: 1.0.15
|
||||
js-yaml: 4.1.0
|
||||
jsonpointer: 5.0.1
|
||||
langsmith: 0.3.30(openai@4.104.0(zod@3.25.62))
|
||||
openapi-types: 12.1.3
|
||||
p-retry: 4.6.2
|
||||
uuid: 10.0.0
|
||||
yaml: 2.5.1
|
||||
zod: 3.25.62
|
||||
optionalDependencies:
|
||||
'@langchain/anthropic': 0.3.22(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))
|
||||
'@langchain/aws': 0.1.11(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))
|
||||
'@langchain/google-genai': 0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))
|
||||
'@langchain/google-vertexai': 0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(zod@3.25.62)
|
||||
axios: 1.8.2
|
||||
cheerio: 1.0.0
|
||||
handlebars: 4.7.8
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- openai
|
||||
- ws
|
||||
|
||||
langfuse-core@3.38.4:
|
||||
dependencies:
|
||||
mustache: 4.2.0
|
||||
|
||||
langfuse-langchain@3.38.4(langchain@0.3.28(@langchain/anthropic@0.3.22(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/aws@0.1.11(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(@langchain/google-genai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/google-vertexai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(zod@3.25.62))(axios@1.8.2)(cheerio@1.0.0)(handlebars@4.7.8)(openai@4.104.0(zod@3.25.62))):
|
||||
langfuse-langchain@3.38.4(langchain@0.3.28(@langchain/anthropic@0.3.22(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/aws@0.1.11(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(@langchain/google-genai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/google-vertexai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(zod@3.25.62))(axios@1.11.0)(cheerio@1.0.0)(handlebars@4.7.8)(openai@4.104.0(zod@3.25.62))):
|
||||
dependencies:
|
||||
langchain: 0.3.28(@langchain/anthropic@0.3.22(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/aws@0.1.11(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(@langchain/google-genai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/google-vertexai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(zod@3.25.62))(axios@1.8.2)(cheerio@1.0.0)(handlebars@4.7.8)(openai@4.104.0(zod@3.25.62))
|
||||
langchain: 0.3.28(@langchain/anthropic@0.3.22(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/aws@0.1.11(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(@langchain/google-genai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/google-vertexai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(zod@3.25.62))(axios@1.11.0)(cheerio@1.0.0)(handlebars@4.7.8)(openai@4.104.0(zod@3.25.62))
|
||||
langfuse: 3.38.4
|
||||
langfuse-core: 3.38.4
|
||||
|
||||
@@ -23740,7 +23681,7 @@ snapshots:
|
||||
openai@4.104.0(zod@3.25.32):
|
||||
dependencies:
|
||||
'@types/node': 18.19.120
|
||||
'@types/node-fetch': 2.6.12
|
||||
'@types/node-fetch': 2.6.13
|
||||
abort-controller: 3.0.0
|
||||
agentkeepalive: 4.6.0
|
||||
form-data-encoder: 1.7.2
|
||||
@@ -23754,7 +23695,7 @@ snapshots:
|
||||
openai@4.104.0(zod@3.25.62):
|
||||
dependencies:
|
||||
'@types/node': 18.19.120
|
||||
'@types/node-fetch': 2.6.12
|
||||
'@types/node-fetch': 2.6.13
|
||||
abort-controller: 3.0.0
|
||||
agentkeepalive: 4.6.0
|
||||
form-data-encoder: 1.7.2
|
||||
@@ -26218,7 +26159,7 @@ snapshots:
|
||||
acorn: 8.15.0
|
||||
browserslist: 4.25.1
|
||||
chrome-trace-event: 1.0.4
|
||||
enhanced-resolve: 5.18.2
|
||||
enhanced-resolve: 5.18.3
|
||||
es-module-lexer: 1.7.0
|
||||
eslint-scope: 5.1.1
|
||||
events: 3.3.0
|
||||
|
||||
+2
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "3.95.2",
|
||||
"version": "3.97.3",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -9,7 +9,7 @@
|
||||
"scripts": {
|
||||
"build": "INLINE_RUNTIME_CHUNK=false dotenv -e ../.env -- next build",
|
||||
"dev": "dotenv -e ../.env -- next dev",
|
||||
"dev:https": "dotenv -e ../.env -- next dev --experimental-https --experimental-https-key ./localhost+1-key.pem --experimental-https-cert ./localhost+1.pem",
|
||||
"dev:http": "dotenv -e ../.env -- next dev --experimental-https --experimental-https-key ./localhost+1-key.pem --experimental-https-cert ./localhost+1.pem",
|
||||
"lint": "dotenv -e ../.env -- next lint --max-warnings 0",
|
||||
"lint:fix": "dotenv -e ../.env -- next lint --fix",
|
||||
"clean": "rm -rf node_modules",
|
||||
@@ -84,8 +84,6 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@remixicon/react": "^4.2.0",
|
||||
"@sentry/nextjs": "^8.52.0",
|
||||
"@slack/oauth": "^2.6.0",
|
||||
"@slack/web-api": "^7.0.0",
|
||||
"@t3-oss/env-nextjs": "^0.11.1",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
type PivotTableConfig,
|
||||
type DatabaseRow,
|
||||
type PivotTableRow,
|
||||
getNextSortState,
|
||||
} from "@/src/features/widgets/utils/pivot-table-utils";
|
||||
|
||||
describe("pivot-table-utils", () => {
|
||||
@@ -1014,4 +1015,113 @@ describe("pivot-table-utils", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("getNextSortState", () => {
|
||||
const defaultSort = { column: "avg_cost", order: "DESC" as const };
|
||||
|
||||
describe("no current sort", () => {
|
||||
test("returns DESC when no current sort", () => {
|
||||
const next = getNextSortState(null, null, "count");
|
||||
expect(next).toEqual({ column: "count", order: "DESC" });
|
||||
});
|
||||
|
||||
test("returns DESC when no current sort with default sort defined", () => {
|
||||
const next = getNextSortState(defaultSort, null, "count");
|
||||
expect(next).toEqual({ column: "count", order: "DESC" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("different column", () => {
|
||||
test("returns DESC when different column", () => {
|
||||
const next = getNextSortState(
|
||||
null,
|
||||
{ column: "metric", order: "ASC" },
|
||||
"count",
|
||||
);
|
||||
expect(next).toEqual({ column: "count", order: "DESC" });
|
||||
});
|
||||
|
||||
test("returns DESC when different column with default sort", () => {
|
||||
const next = getNextSortState(
|
||||
defaultSort,
|
||||
{ column: "metric", order: "ASC" },
|
||||
"count",
|
||||
);
|
||||
expect(next).toEqual({ column: "count", order: "DESC" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("non-default column cycling", () => {
|
||||
test("non-default column: DESC -> ASC", () => {
|
||||
const next = getNextSortState(
|
||||
defaultSort,
|
||||
{ column: "count", order: "DESC" },
|
||||
"count",
|
||||
);
|
||||
expect(next).toEqual({ column: "count", order: "ASC" });
|
||||
});
|
||||
|
||||
test("non-default column: ASC -> default sort", () => {
|
||||
const next = getNextSortState(
|
||||
defaultSort,
|
||||
{ column: "count", order: "ASC" },
|
||||
"count",
|
||||
);
|
||||
expect(next).toEqual(defaultSort);
|
||||
});
|
||||
|
||||
test("non-default column: ASC -> null when no default sort", () => {
|
||||
const next = getNextSortState(
|
||||
null,
|
||||
{ column: "count", order: "ASC" },
|
||||
"count",
|
||||
);
|
||||
expect(next).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("default column cycling", () => {
|
||||
test("default column: DESC -> ASC", () => {
|
||||
const next = getNextSortState(
|
||||
defaultSort,
|
||||
{ column: "avg_cost", order: "DESC" },
|
||||
"avg_cost",
|
||||
);
|
||||
expect(next).toEqual({ column: "avg_cost", order: "ASC" });
|
||||
});
|
||||
|
||||
test("default column: ASC -> DESC (infinite cycle)", () => {
|
||||
const next = getNextSortState(
|
||||
defaultSort,
|
||||
{ column: "avg_cost", order: "ASC" },
|
||||
"avg_cost",
|
||||
);
|
||||
expect(next).toEqual({ column: "avg_cost", order: "DESC" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
test("handles undefined default sort", () => {
|
||||
const next = getNextSortState(
|
||||
undefined as any,
|
||||
{ column: "count", order: "ASC" },
|
||||
"count",
|
||||
);
|
||||
expect(next).toBeNull();
|
||||
});
|
||||
|
||||
test("handles null default sort", () => {
|
||||
const next = getNextSortState(
|
||||
null,
|
||||
{ column: "count", order: "ASC" },
|
||||
"count",
|
||||
);
|
||||
expect(next).toBeNull();
|
||||
});
|
||||
|
||||
test("handles null current sort with defined default", () => {
|
||||
const next = getNextSortState(defaultSort, null, "count");
|
||||
expect(next).toEqual({ column: "count", order: "DESC" });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
IngestionQueue,
|
||||
logger,
|
||||
QueueName,
|
||||
TraceUpsertQueue,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { type z } from "zod/v4";
|
||||
|
||||
@@ -101,7 +102,10 @@ export const pruneDatabase = async () => {
|
||||
};
|
||||
export const getQueues = () => {
|
||||
const queues: string[] = Object.values(QueueName);
|
||||
queues.push(...IngestionQueue.getShardNames());
|
||||
queues.push(
|
||||
...IngestionQueue.getShardNames(),
|
||||
...TraceUpsertQueue.getShardNames(),
|
||||
);
|
||||
|
||||
const listOfQueuesToIgnore = [
|
||||
QueueName.DataRetentionQueue,
|
||||
@@ -117,7 +121,14 @@ export const getQueues = () => {
|
||||
.map((queueName) =>
|
||||
queueName.startsWith(QueueName.IngestionQueue)
|
||||
? IngestionQueue.getInstance({ shardName: queueName })
|
||||
: getQueue(queueName as Exclude<QueueName, QueueName.IngestionQueue>),
|
||||
: queueName.startsWith(QueueName.TraceUpsert)
|
||||
? TraceUpsertQueue.getInstance({ shardName: queueName })
|
||||
: getQueue(
|
||||
queueName as Exclude<
|
||||
QueueName,
|
||||
QueueName.IngestionQueue | QueueName.TraceUpsert
|
||||
>,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -61,6 +61,9 @@ export const ModelParameters: React.FC<ModelParamsContext> = ({
|
||||
const [modelSettingsOpen, setModelSettingsOpen] = useState(false);
|
||||
const [modelSettingsUsed, setModelSettingsUsed] = useState(false);
|
||||
|
||||
const [createLlmApiKeyDialogOpen, setCreateLlmApiKeyDialogOpen] =
|
||||
useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const hasEnabledModelSetting = Object.keys(modelParams).some(
|
||||
(key) =>
|
||||
@@ -88,7 +91,10 @@ export const ModelParameters: React.FC<ModelParamsContext> = ({
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs">No LLM API key set in project. </p>
|
||||
<CreateLLMApiKeyDialog />
|
||||
<CreateLLMApiKeyDialog
|
||||
open={createLlmApiKeyDialogOpen}
|
||||
setOpen={setCreateLlmApiKeyDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -204,7 +210,10 @@ export const ModelParameters: React.FC<ModelParamsContext> = ({
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectSeparator />
|
||||
<CreateLLMApiKeyDialog />
|
||||
<CreateLLMApiKeyDialog
|
||||
open={createLlmApiKeyDialogOpen}
|
||||
setOpen={setCreateLlmApiKeyDialogOpen}
|
||||
/>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{modelParamsDescription ? (
|
||||
@@ -304,6 +313,9 @@ const ModelParamsSelect = ({
|
||||
modelParamsDescription,
|
||||
layout = "vertical",
|
||||
}: ModelParamsSelectProps) => {
|
||||
const [createLlmApiKeyDialogOpen, setCreateLlmApiKeyDialogOpen] =
|
||||
useState(false);
|
||||
|
||||
// Compact layout - simplified, space-efficient (no individual labels)
|
||||
if (layout === "compact") {
|
||||
return (
|
||||
@@ -328,7 +340,10 @@ const ModelParamsSelect = ({
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectSeparator />
|
||||
<CreateLLMApiKeyDialog />
|
||||
<CreateLLMApiKeyDialog
|
||||
open={createLlmApiKeyDialogOpen}
|
||||
setOpen={setCreateLlmApiKeyDialogOpen}
|
||||
/>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{modelParamsDescription ? (
|
||||
@@ -374,7 +389,10 @@ const ModelParamsSelect = ({
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectSeparator />
|
||||
<CreateLLMApiKeyDialog />
|
||||
<CreateLLMApiKeyDialog
|
||||
open={createLlmApiKeyDialogOpen}
|
||||
setOpen={setCreateLlmApiKeyDialogOpen}
|
||||
/>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{modelParamsDescription ? (
|
||||
|
||||
@@ -50,7 +50,7 @@ export const PeekViewEvaluatorConfigDetail = ({
|
||||
<span className="max-h-fit text-lg font-medium">Configuration</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge
|
||||
type={evalConfig.status.toLowerCase()}
|
||||
type={evalConfig.finalStatus.toLowerCase()}
|
||||
isLive
|
||||
className="max-h-8"
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import * as React from "react";
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
@@ -33,7 +32,7 @@ const RadioGroupItem = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Check className="h-3.5 w-3.5 fill-primary" />
|
||||
<div className="h-2 w-2 rounded-full bg-primary" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const VERSION = "v3.95.2";
|
||||
export const VERSION = "v3.97.3";
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -66,7 +66,7 @@ export function RequestResetPasswordEmailButton({
|
||||
const callback = encodeURIComponent(
|
||||
`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/auth/reset-password`,
|
||||
);
|
||||
const url = `/api/auth/callback/email?email=${formattedEmail}&token=${formattedCode}&callbackUrl=${callback}`;
|
||||
const url = `${env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/auth/callback/email?email=${formattedEmail}&token=${formattedCode}&callbackUrl=${callback}`;
|
||||
window.location.href = url;
|
||||
} catch (error) {
|
||||
console.error("Error verifying code:", error);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import type { Path } from "react-hook-form";
|
||||
import { useRouter } from "next/router";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Form } from "@/src/components/ui/form";
|
||||
import { LangfuseIcon } from "@/src/components/LangfuseLogo";
|
||||
import { useSurveyForm } from "../hooks/useSurveyForm";
|
||||
import { SurveyProgress } from "./SurveyProgress";
|
||||
import { SurveyStep } from "./SurveyStep";
|
||||
import type { SurveyFormData } from "../lib/surveyTypes";
|
||||
|
||||
export function OnboardingSurvey() {
|
||||
const router = useRouter();
|
||||
const {
|
||||
form,
|
||||
state,
|
||||
currentQuestion,
|
||||
isLastStep,
|
||||
isFirstStep,
|
||||
goNext,
|
||||
goBack,
|
||||
handleAutoAdvance,
|
||||
handleSubmit,
|
||||
totalSteps,
|
||||
} = useSurveyForm();
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (data: SurveyFormData) => {
|
||||
await handleSubmit(data);
|
||||
void router.push("/");
|
||||
},
|
||||
[handleSubmit, router],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux) on referralSource step submits the form
|
||||
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
||||
if (currentQuestion?.id === "referralSource") {
|
||||
event.preventDefault();
|
||||
form.handleSubmit(onSubmit)();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Regular Enter advances to next step (existing behavior)
|
||||
if (event.key === "Enter" && !isLastStep) {
|
||||
if (currentQuestion?.type !== "text") {
|
||||
goNext();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [
|
||||
isLastStep,
|
||||
currentQuestion?.type,
|
||||
currentQuestion?.id,
|
||||
goNext,
|
||||
form,
|
||||
onSubmit,
|
||||
]);
|
||||
|
||||
// Auto-focus the first form control of the current step
|
||||
useEffect(() => {
|
||||
if (!currentQuestion?.id) return;
|
||||
const field = currentQuestion.id as Path<SurveyFormData>;
|
||||
const raf = requestAnimationFrame(() => {
|
||||
try {
|
||||
form.setFocus(field, { shouldSelect: true });
|
||||
} catch {
|
||||
// ignore if the control cannot be focused
|
||||
}
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [currentQuestion?.id, form]);
|
||||
|
||||
// Determine labeling/behavior of the primary (right) button
|
||||
const roleValue = form.watch("role");
|
||||
const signupReasonValue = form.watch("signupReason");
|
||||
const referralSourceValue = form.watch("referralSource");
|
||||
|
||||
const currentFieldId = currentQuestion?.id as
|
||||
| keyof SurveyFormData
|
||||
| undefined;
|
||||
const currentValue = currentFieldId
|
||||
? form.watch(currentFieldId as Path<SurveyFormData>)
|
||||
: undefined;
|
||||
|
||||
const isEmpty = (v: unknown) =>
|
||||
v == null || (typeof v === "string" && v.trim() === "");
|
||||
const allFields = {
|
||||
role: roleValue,
|
||||
signupReason: signupReasonValue,
|
||||
referralSource: referralSourceValue,
|
||||
} as const;
|
||||
|
||||
const currentEmpty = isEmpty(currentValue);
|
||||
const otherTwoEmpty = Object.entries(allFields)
|
||||
.filter(([key]) => key !== currentFieldId)
|
||||
.every(([, v]) => isEmpty(v));
|
||||
// showSkip: ghost button labeled "Skip" when skipping is the intended action
|
||||
const showSkip = isLastStep ? currentEmpty && otherTwoEmpty : currentEmpty;
|
||||
|
||||
const handleSkipButton = () => {
|
||||
if (isLastStep) {
|
||||
void router.push("/");
|
||||
} else {
|
||||
goNext();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitButton = () => {
|
||||
if (isLastStep) {
|
||||
form.handleSubmit(onSubmit)();
|
||||
} else {
|
||||
goNext();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col py-6 sm:min-h-full sm:justify-start sm:px-6 sm:py-12 lg:px-8">
|
||||
<div className="flex items-center justify-center gap-2 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<LangfuseIcon className="h-8 w-8" />
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-lg bg-background px-6 py-6 shadow sm:mx-auto sm:mt-16 sm:w-full sm:max-w-[480px] sm:px-12 sm:py-10">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="flex h-full flex-col"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<div className="flex-1">
|
||||
{currentQuestion && (
|
||||
<SurveyStep
|
||||
question={currentQuestion}
|
||||
control={form.control}
|
||||
onAutoAdvance={handleAutoAdvance}
|
||||
isLast={isLastStep}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row-reverse items-center justify-between pt-6">
|
||||
{showSkip ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSkipButton}
|
||||
variant="ghost"
|
||||
className="w-20"
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmitButton}
|
||||
variant="default"
|
||||
className="w-20"
|
||||
>
|
||||
{isLastStep ? "Finish" : "Next"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="basis-[10rem] px-4">
|
||||
<SurveyProgress
|
||||
currentStep={state.currentStep}
|
||||
totalSteps={totalSteps}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isFirstStep ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={goBack}
|
||||
className="w-20"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
) : (
|
||||
<div className="w-20" />
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
|
||||
interface SurveyProgressProps {
|
||||
currentStep: number;
|
||||
totalSteps: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SurveyProgress({
|
||||
currentStep,
|
||||
totalSteps,
|
||||
className,
|
||||
}: SurveyProgressProps) {
|
||||
return (
|
||||
<div className={cn("flex w-full gap-3", className)}>
|
||||
{Array.from({ length: totalSteps }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"h-1 flex-1 rounded-full transition-colors duration-300",
|
||||
index <= currentStep ? "bg-primary" : "bg-muted",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/src/components/ui/form";
|
||||
import { RadioGroup, RadioGroupItem } from "@/src/components/ui/radio-group";
|
||||
import { Textarea } from "@/src/components/ui/textarea";
|
||||
import { Input } from "@/src/components/ui/input";
|
||||
import { Label } from "@/src/components/ui/label";
|
||||
import type { Control, Path } from "react-hook-form";
|
||||
import type { SurveyQuestion, SurveyFormData } from "../lib/surveyTypes";
|
||||
|
||||
const AUTO_ADVANCE_DELAY = 300;
|
||||
|
||||
interface SurveyStepProps {
|
||||
question: SurveyQuestion;
|
||||
control: Control<SurveyFormData>;
|
||||
onAutoAdvance?: (selectedValue?: string) => void;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
export function SurveyStep({
|
||||
question,
|
||||
control,
|
||||
onAutoAdvance,
|
||||
isLast = false,
|
||||
}: SurveyStepProps) {
|
||||
const fieldName = question.id as keyof SurveyFormData;
|
||||
|
||||
const handleAutoAdvanceWithTimeout = (selectedValue?: string) => {
|
||||
if (onAutoAdvance) {
|
||||
// For signupReason question, ignore isLast and let the hook decide
|
||||
// For other questions, respect the isLast prop
|
||||
const shouldAutoAdvance = question.id === "signupReason" || !isLast;
|
||||
|
||||
if (shouldAutoAdvance) {
|
||||
setTimeout(() => {
|
||||
onAutoAdvance(selectedValue);
|
||||
}, AUTO_ADVANCE_DELAY);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (question.type === "radio") {
|
||||
return (
|
||||
<FormField
|
||||
key={fieldName}
|
||||
control={control}
|
||||
name={fieldName as Path<SurveyFormData>}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col gap-2">
|
||||
<FormLabel className="text-xl font-semibold">
|
||||
{question.question}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
setTimeout(() => {
|
||||
handleAutoAdvanceWithTimeout(value);
|
||||
}, 0);
|
||||
}}
|
||||
value={field.value as string}
|
||||
className="grid gap-3"
|
||||
>
|
||||
{question.options.map((option) => (
|
||||
<Label
|
||||
key={option}
|
||||
htmlFor={option}
|
||||
className="flex flex-1 cursor-pointer items-center gap-3 rounded-lg border border-border p-3 text-sm font-medium leading-none transition-colors hover:bg-muted/50 peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
<RadioGroupItem value={option} id={option} />
|
||||
<span className="flex-1">{option}</span>
|
||||
</Label>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (question.type === "text") {
|
||||
return (
|
||||
<FormField
|
||||
key={fieldName}
|
||||
control={control}
|
||||
name={fieldName as Path<SurveyFormData>}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col gap-2">
|
||||
<FormLabel className="text-xl font-semibold">
|
||||
{question.question}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
{question.id === "referralSource" ? (
|
||||
<Input placeholder={question.placeholder} {...field} />
|
||||
) : (
|
||||
<Textarea
|
||||
placeholder={question.placeholder}
|
||||
className="min-h-[170px] resize-none"
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useReducer, useCallback } from "react";
|
||||
import type { SurveyFormData } from "../lib/surveyTypes";
|
||||
import { surveyReducer, initialSurveyState } from "../lib/surveyReducer";
|
||||
import { SURVEY_QUESTIONS, TOTAL_STEPS } from "../lib/questions";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { SurveyName } from "@prisma/client";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { showErrorToast } from "@/src/features/notifications/showErrorToast";
|
||||
import { showSuccessToast } from "@/src/features/notifications/showSuccessToast";
|
||||
|
||||
export function useSurveyForm() {
|
||||
const [state, dispatch] = useReducer(surveyReducer, initialSurveyState);
|
||||
const { data: session } = useSession();
|
||||
const createSurveyMutation = api.surveys.create.useMutation({
|
||||
onSuccess: () => {
|
||||
showSuccessToast({
|
||||
title: "Survey submitted",
|
||||
description: "Thank you for your feedback!",
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorToast(
|
||||
"Failed to submit survey",
|
||||
error.message || "Please try again later.",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm<SurveyFormData>({
|
||||
defaultValues: {
|
||||
role: undefined,
|
||||
signupReason: undefined,
|
||||
referralSource: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// Check if we should skip the referral source question
|
||||
const signupReason = form.watch("signupReason");
|
||||
const shouldSkipReferralQuestion = signupReason === "Invited by team";
|
||||
|
||||
// Get effective total steps (skip last question if invited by team)
|
||||
const effectiveTotalSteps = shouldSkipReferralQuestion
|
||||
? TOTAL_STEPS - 1
|
||||
: TOTAL_STEPS;
|
||||
|
||||
// Get current question, but skip the referral question if "Invited by team" is selected
|
||||
const currentQuestion = SURVEY_QUESTIONS[state.currentStep];
|
||||
|
||||
const isLastStep = state.currentStep === effectiveTotalSteps - 1;
|
||||
const isFirstStep = state.currentStep === 0;
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
dispatch({ type: "next" });
|
||||
}, []);
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
dispatch({ type: "back" });
|
||||
}, []);
|
||||
|
||||
const goToStep = useCallback((step: number) => {
|
||||
dispatch({ type: "goToStep", step });
|
||||
}, []);
|
||||
|
||||
const handleAutoAdvance = useCallback(
|
||||
(selectedValue?: string) => {
|
||||
// Special case: if we're on step 1 (signup reason) and "Invited by team" was selected,
|
||||
// don't auto-advance, let the user click "Finish"
|
||||
if (state.currentStep === 1 && selectedValue === "Invited by team") {
|
||||
return;
|
||||
}
|
||||
|
||||
// For all other cases, advance to next step
|
||||
goNext();
|
||||
},
|
||||
[state.currentStep, goNext],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (data: SurveyFormData) => {
|
||||
const transformedResponse: Record<string, string> = {};
|
||||
if (data.role) transformedResponse["role"] = data.role;
|
||||
if (data.signupReason)
|
||||
transformedResponse["signupReason"] = data.signupReason;
|
||||
if (data.referralSource)
|
||||
transformedResponse["referralSource"] = data.referralSource.trim();
|
||||
|
||||
try {
|
||||
await createSurveyMutation.mutateAsync({
|
||||
surveyName: SurveyName.USER_ONBOARDING,
|
||||
response: transformedResponse,
|
||||
orgId: session?.user?.organizations?.[0]?.id,
|
||||
});
|
||||
} catch (error) {
|
||||
// Error handling is done in the mutation callbacks
|
||||
// This catch block is for any additional error handling if needed
|
||||
}
|
||||
},
|
||||
[createSurveyMutation, session],
|
||||
);
|
||||
|
||||
return {
|
||||
form,
|
||||
state,
|
||||
currentQuestion,
|
||||
isLastStep,
|
||||
isFirstStep,
|
||||
goNext,
|
||||
goBack,
|
||||
goToStep,
|
||||
handleAutoAdvance,
|
||||
handleSubmit,
|
||||
totalSteps: effectiveTotalSteps,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { SurveyQuestion } from "./surveyTypes";
|
||||
|
||||
export const SURVEY_QUESTIONS: SurveyQuestion[] = [
|
||||
{
|
||||
id: "role",
|
||||
type: "radio",
|
||||
question: "What describes you best?",
|
||||
options: [
|
||||
"Software Engineer",
|
||||
"ML Engineer / Data Scientist",
|
||||
"Product Manager",
|
||||
"Domain Expert",
|
||||
"Executive or Manager",
|
||||
"Other",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "signupReason",
|
||||
type: "radio",
|
||||
question: "Why are you signing up?",
|
||||
options: [
|
||||
"Invited by team",
|
||||
"Just looking around",
|
||||
"Evaluating / Testing Langfuse",
|
||||
"Start using Langfuse",
|
||||
"Migrating from other solution",
|
||||
"Migrating from self-hosted",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "referralSource",
|
||||
type: "text",
|
||||
question: "Where did you hear about us?",
|
||||
placeholder: "GitHub, X, Reddit, colleague etc.",
|
||||
},
|
||||
];
|
||||
|
||||
export const TOTAL_STEPS = SURVEY_QUESTIONS.length;
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { SurveyState, SurveyAction } from "./surveyTypes";
|
||||
import { TOTAL_STEPS } from "./questions";
|
||||
|
||||
export const initialSurveyState: SurveyState = {
|
||||
currentStep: 0,
|
||||
};
|
||||
|
||||
export function surveyReducer(
|
||||
state: SurveyState,
|
||||
action: SurveyAction,
|
||||
): SurveyState {
|
||||
switch (action.type) {
|
||||
case "next":
|
||||
return {
|
||||
...state,
|
||||
currentStep: Math.min(state.currentStep + 1, TOTAL_STEPS - 1),
|
||||
};
|
||||
case "back":
|
||||
return {
|
||||
...state,
|
||||
currentStep: Math.max(state.currentStep - 1, 0),
|
||||
};
|
||||
case "goToStep":
|
||||
return {
|
||||
...state,
|
||||
currentStep: Math.max(0, Math.min(action.step, TOTAL_STEPS - 1)),
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export type QuestionType = "radio" | "text";
|
||||
|
||||
export interface BaseQuestion {
|
||||
id: string;
|
||||
type: QuestionType;
|
||||
question: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export interface RadioQuestion extends BaseQuestion {
|
||||
type: "radio";
|
||||
options: string[];
|
||||
}
|
||||
|
||||
export interface TextQuestion extends BaseQuestion {
|
||||
type: "text";
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export type SurveyQuestion = RadioQuestion | TextQuestion;
|
||||
|
||||
export interface SurveyState {
|
||||
currentStep: number;
|
||||
}
|
||||
|
||||
export type SurveyAction =
|
||||
| { type: "next" }
|
||||
| { type: "back" }
|
||||
| { type: "goToStep"; step: number };
|
||||
|
||||
export interface SurveyFormData {
|
||||
role?: string;
|
||||
signupReason?: string;
|
||||
referralSource?: string;
|
||||
}
|
||||
|
||||
export interface SurveyStepProps {
|
||||
question: SurveyQuestion;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
onNext?: () => void;
|
||||
showNext?: boolean;
|
||||
isLast?: boolean;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { useEffect } from "react";
|
||||
import type * as z from "zod/v4";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -9,12 +10,22 @@ import {
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
FormDescription,
|
||||
} from "@/src/components/ui/form";
|
||||
import { Input } from "@/src/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} 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";
|
||||
|
||||
export const NewOrganizationForm = ({
|
||||
onSuccess,
|
||||
@@ -24,23 +35,49 @@ export const NewOrganizationForm = ({
|
||||
const { update: updateSession } = useSession();
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(organizationNameSchema),
|
||||
resolver: zodResolver(organizationFormSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
type: "Personal",
|
||||
size: undefined,
|
||||
},
|
||||
});
|
||||
const capture = usePostHogClientCapture();
|
||||
const createOrgMutation = api.organizations.create.useMutation({
|
||||
onError: (error) => form.setError("name", { message: error.message }),
|
||||
});
|
||||
const createSurveyMutation = api.surveys.create.useMutation();
|
||||
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({
|
||||
name: values.name,
|
||||
})
|
||||
.then((org) => {
|
||||
.then(async (org) => {
|
||||
// Submit survey with organization data only on Cloud and if type is provided
|
||||
if (isCloud && values.type) {
|
||||
const surveyResponse: Record<string, string> = {
|
||||
type: values.type,
|
||||
};
|
||||
if (values.size) {
|
||||
surveyResponse.size = values.size;
|
||||
}
|
||||
|
||||
try {
|
||||
await createSurveyMutation.mutateAsync({
|
||||
surveyName: SurveyName.ORG_ONBOARDING,
|
||||
response: surveyResponse,
|
||||
orgId: org.id,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to submit survey:", error);
|
||||
// Continue with organization creation even if survey fails
|
||||
}
|
||||
}
|
||||
|
||||
void updateSession();
|
||||
onSuccess(org.id);
|
||||
form.reset();
|
||||
@@ -50,6 +87,13 @@ export const NewOrganizationForm = ({
|
||||
});
|
||||
}
|
||||
|
||||
// Clear size whenever type is not Company or Agency to avoid submitting hidden values
|
||||
useEffect(() => {
|
||||
if (watchedType !== "Company" && watchedType !== "Agency") {
|
||||
form.setValue("size", undefined);
|
||||
}
|
||||
}, [watchedType, form]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
@@ -75,6 +119,69 @@ export const NewOrganizationForm = ({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{isCloud && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Type</FormLabel>
|
||||
<FormDescription>
|
||||
What would best describe your organization?
|
||||
</FormDescription>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger ref={field.ref}>
|
||||
<SelectValue placeholder="Please choose" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="Personal">Personal</SelectItem>
|
||||
<SelectItem value="Educational">Educational</SelectItem>
|
||||
<SelectItem value="Company">Company</SelectItem>
|
||||
<SelectItem value="Startup">Startup</SelectItem>
|
||||
<SelectItem value="Agency">Agency</SelectItem>
|
||||
<SelectItem value="N/A">N/A</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{(watchedType === "Company" || watchedType === "Agency") && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="size"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{watchedType} size</FormLabel>
|
||||
<FormDescription>
|
||||
How many people are in your {watchedType}?
|
||||
</FormDescription>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger ref={field.ref}>
|
||||
<SelectValue placeholder="Please choose" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="1-10">1-10</SelectItem>
|
||||
<SelectItem value="10-49">10-49</SelectItem>
|
||||
<SelectItem value="50-99">50-99</SelectItem>
|
||||
<SelectItem value="100-299">100-299</SelectItem>
|
||||
<SelectItem value="More than 300">
|
||||
More than 300
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Button type="submit" loading={createOrgMutation.isLoading}>
|
||||
Create
|
||||
</Button>
|
||||
|
||||
@@ -1,9 +1,46 @@
|
||||
import { StringNoHTML } from "@langfuse/shared";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
const organizationTypeOptions = [
|
||||
"Personal",
|
||||
"Educational",
|
||||
"Company",
|
||||
"Startup",
|
||||
"Agency",
|
||||
"N/A",
|
||||
] as const;
|
||||
|
||||
const organizationSizeOptions = [
|
||||
"1-10",
|
||||
"10-49",
|
||||
"50-99",
|
||||
"100-299",
|
||||
"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",
|
||||
),
|
||||
});
|
||||
|
||||
// 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",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -14,9 +12,14 @@ import useProjectIdFromURL from "@/src/hooks/useProjectIdFromURL";
|
||||
import { useUiCustomization } from "@/src/ee/features/ui-customization/useUiCustomization";
|
||||
import { CreateLLMApiKeyForm } from "@/src/features/public-api/components/CreateLLMApiKeyForm";
|
||||
|
||||
export function CreateLLMApiKeyDialog() {
|
||||
export function CreateLLMApiKeyDialog({
|
||||
open,
|
||||
setOpen,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
}) {
|
||||
const projectId = useProjectIdFromURL();
|
||||
const [open, setOpen] = useState(false);
|
||||
const hasAccess = useHasProjectAccess({
|
||||
projectId,
|
||||
scope: "llmApiKeys:create",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -29,6 +29,7 @@ import { UpdateLLMApiKeyDialog } from "./UpdateLLMApiKeyDialog";
|
||||
|
||||
export function LlmApiKeyList(props: { projectId: string }) {
|
||||
const [editingKeyId, setEditingKeyId] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const hasAccess = useHasProjectAccess({
|
||||
projectId: props.projectId,
|
||||
@@ -148,7 +149,7 @@ export function LlmApiKeyList(props: { projectId: string }) {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
<CreateLLMApiKeyDialog />
|
||||
<CreateLLMApiKeyDialog open={open} setOpen={setOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -458,7 +458,7 @@ const scoreBaseDimensions: DimensionsDeclarationType = {
|
||||
export const scoresNumericView: ViewDeclarationType = {
|
||||
name: "scores_numeric",
|
||||
description:
|
||||
"Scores are flexible objects that are used for evaluations. This view contains numeric scores.",
|
||||
"Scores are flexible objects that are used for evaluations. This view contains numeric and boolean scores.",
|
||||
dimensions: {
|
||||
...scoreBaseDimensions,
|
||||
id: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
import React from "react";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { X } from "lucide-react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
|
||||
type TagInputProps = React.ComponentPropsWithoutRef<
|
||||
typeof CommandPrimitive.Input
|
||||
> & {
|
||||
selectedTags: string[];
|
||||
setSelectedTags: (tags: string[]) => void;
|
||||
setSelectedTags?: (tags: string[]) => void;
|
||||
};
|
||||
|
||||
export const TagInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
TagInputProps
|
||||
>(({ className, selectedTags, setSelectedTags, ...props }, ref) => {
|
||||
const capture = usePostHogClientCapture();
|
||||
>(({ className, selectedTags, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-wrap items-center overflow-auto rounded-lg border px-2"
|
||||
@@ -29,16 +26,10 @@ export const TagInput = React.forwardRef<
|
||||
key={tag}
|
||||
variant="tertiary"
|
||||
size="icon-sm"
|
||||
onClick={() => {
|
||||
const newTags = selectedTags.filter((t) => t !== tag);
|
||||
setSelectedTags(newTags);
|
||||
capture("tag:remove_tag", {
|
||||
name: tag,
|
||||
});
|
||||
}}
|
||||
disabled
|
||||
className="cursor-default"
|
||||
>
|
||||
{tag}
|
||||
<X className="ml-1 h-3 w-3" />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -12,12 +12,16 @@ import { Button } from "@/src/components/ui/button";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { BigNumber } from "@/src/features/widgets/chart-library/BigNumber";
|
||||
import { PivotTable } from "@/src/features/widgets/chart-library/PivotTable";
|
||||
import { type OrderByState } from "@langfuse/shared";
|
||||
|
||||
export const Chart = ({
|
||||
chartType,
|
||||
data,
|
||||
rowLimit,
|
||||
chartConfig,
|
||||
sortState,
|
||||
onSortChange,
|
||||
isLoading = false,
|
||||
}: {
|
||||
chartType: DashboardWidgetChartType;
|
||||
data: DataPoint[];
|
||||
@@ -28,7 +32,11 @@ export const Chart = ({
|
||||
bins?: number;
|
||||
dimensions?: string[];
|
||||
metrics?: string[];
|
||||
defaultSort?: OrderByState;
|
||||
};
|
||||
sortState?: OrderByState | null;
|
||||
onSortChange?: (sortState: OrderByState | null) => void;
|
||||
isLoading?: boolean;
|
||||
}) => {
|
||||
const [forceRender, setForceRender] = useState(false);
|
||||
const shouldWarn = data.length > 2000 && !forceRender;
|
||||
@@ -73,8 +81,17 @@ export const Chart = ({
|
||||
dimensions: chartConfig?.dimensions ?? [],
|
||||
metrics: chartConfig?.metrics ?? ["metric"], // Use metrics from chartConfig
|
||||
rowLimit: chartConfig?.row_limit ?? rowLimit,
|
||||
defaultSort: chartConfig?.defaultSort,
|
||||
};
|
||||
return <PivotTable data={renderedData} config={pivotConfig} />;
|
||||
return (
|
||||
<PivotTable
|
||||
data={renderedData}
|
||||
config={pivotConfig}
|
||||
sortState={sortState}
|
||||
onSortChange={onSortChange}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return <HorizontalBarChart data={renderedData.slice(0, rowLimit)} />;
|
||||
|
||||
@@ -12,13 +12,14 @@
|
||||
* - Responsive design within dashboard grid
|
||||
* - Consistent styling with Langfuse design system
|
||||
* - Row limiting to prevent performance issues
|
||||
* - Interactive sorting with hierarchical behavior
|
||||
*
|
||||
* Usage:
|
||||
* Used as part of the dashboard widget system to display tabular data
|
||||
* visualizations with grouping and aggregation capabilities.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import React, { useMemo, useCallback, useState, useEffect } from "react";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import {
|
||||
Table,
|
||||
@@ -32,6 +33,8 @@ import {
|
||||
transformToPivotTable,
|
||||
extractDimensionValues,
|
||||
extractMetricValues,
|
||||
sortPivotTableRows,
|
||||
getNextSortState,
|
||||
type PivotTableRow,
|
||||
type PivotTableConfig,
|
||||
type DatabaseRow,
|
||||
@@ -40,6 +43,8 @@ import {
|
||||
import { type ChartProps } from "@/src/features/widgets/chart-library/chart-props";
|
||||
import { numberFormatter } from "@/src/utils/numbers";
|
||||
import { formatMetricName } from "@/src/features/widgets/utils";
|
||||
import { type OrderByState } from "@langfuse/shared";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Props interface for the PivotTable component
|
||||
@@ -57,8 +62,89 @@ export interface PivotTableProps {
|
||||
|
||||
/** Accessibility layer flag */
|
||||
accessibilityLayer?: boolean;
|
||||
|
||||
/** Current sort state */
|
||||
sortState?: OrderByState;
|
||||
|
||||
/** Callback for sort state changes */
|
||||
onSortChange?: (sortState: OrderByState | null) => void;
|
||||
|
||||
/** Loading state for when data is being refreshed */
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-sortable column header component
|
||||
* Simple header without sorting functionality
|
||||
*/
|
||||
const StaticHeader: React.FC<{
|
||||
label: string;
|
||||
className?: string;
|
||||
}> = ({ label, className }) => {
|
||||
return (
|
||||
<TableHead className={cn("p-1", className)}>
|
||||
<div className="flex select-none items-center">
|
||||
<span className="truncate">{label}</span>
|
||||
</div>
|
||||
</TableHead>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sortable column header component
|
||||
* Handles click events and visual indicators for sorting
|
||||
*/
|
||||
const SortableHeader: React.FC<{
|
||||
column: string;
|
||||
label: string;
|
||||
sortState?: OrderByState;
|
||||
onSort: (column: string) => void;
|
||||
className?: string;
|
||||
rightAlign?: boolean;
|
||||
}> = ({ column, label, sortState, onSort, className, rightAlign = false }) => {
|
||||
const isSorted = sortState?.column === column;
|
||||
const sortDirection = isSorted ? sortState.order : null;
|
||||
|
||||
const handleClick = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
onSort(column);
|
||||
},
|
||||
[column, onSort],
|
||||
);
|
||||
|
||||
return (
|
||||
<TableHead
|
||||
className={cn("group/header cursor-pointer select-none p-1", className)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex select-none items-center",
|
||||
rightAlign ? "justify-end" : "justify-start",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{label}</span>
|
||||
{isSorted && (
|
||||
<span
|
||||
className="ml-1"
|
||||
title={
|
||||
sortDirection === "ASC"
|
||||
? "Sorted ascending"
|
||||
: "Sort by this column"
|
||||
}
|
||||
>
|
||||
{sortDirection === "ASC" ? "▲" : "▼"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Visual indicator that appears on hover - matches traces table behavior */}
|
||||
<div className="pointer-events-none absolute right-0 top-0 h-full w-1.5 touch-none select-none bg-secondary opacity-0 group-hover/header:opacity-100" />
|
||||
</div>
|
||||
</TableHead>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Individual row component for the pivot table
|
||||
* Handles styling, indentation, and content display for each row type
|
||||
@@ -143,8 +229,16 @@ function formatColumnHeader(metricName: string): string {
|
||||
*
|
||||
* @param data - Array of data points from the chart query
|
||||
* @param config - Pivot table configuration including dimensions and metrics
|
||||
* @param sortState - Current sort state
|
||||
* @param onSortChange - Callback for sort state changes
|
||||
*/
|
||||
export const PivotTable: React.FC<PivotTableProps> = ({ data, config }) => {
|
||||
export const PivotTable: React.FC<PivotTableProps> = ({
|
||||
data,
|
||||
config,
|
||||
sortState,
|
||||
onSortChange,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
// Transform chart data into pivot table structure
|
||||
const pivotTableRows = useMemo(() => {
|
||||
if (!data || data.length === 0) {
|
||||
@@ -156,6 +250,7 @@ export const PivotTable: React.FC<PivotTableProps> = ({ data, config }) => {
|
||||
dimensions: config?.dimensions ?? [],
|
||||
metrics: config?.metrics ?? ["metric"], // Default to 'metric' field from DataPoint
|
||||
rowLimit: config?.rowLimit ?? DEFAULT_ROW_LIMIT,
|
||||
defaultSort: config?.defaultSort,
|
||||
};
|
||||
|
||||
// Transform DataPoint[] to DatabaseRow[] format using utility functions
|
||||
@@ -206,11 +301,59 @@ export const PivotTable: React.FC<PivotTableProps> = ({ data, config }) => {
|
||||
}
|
||||
}, [data, config]);
|
||||
|
||||
// Apply sorting to pivot table rows
|
||||
const sortedRows = useMemo(() => {
|
||||
// Use user sort state if available, otherwise fall back to default sort
|
||||
|
||||
if (!sortState || !sortState.column) {
|
||||
return pivotTableRows;
|
||||
}
|
||||
|
||||
try {
|
||||
return sortPivotTableRows(pivotTableRows, sortState);
|
||||
} catch (error) {
|
||||
console.error("Error sorting pivot table rows:", error);
|
||||
return pivotTableRows;
|
||||
}
|
||||
}, [pivotTableRows, sortState]);
|
||||
|
||||
// Extract metrics from configuration or fallback to default
|
||||
const metrics = useMemo(() => {
|
||||
return config?.metrics ?? ["metric"];
|
||||
}, [config?.metrics]);
|
||||
|
||||
// Handle sort click events - simple cycling
|
||||
const handleSort = useCallback(
|
||||
(column: string) => {
|
||||
if (!onSortChange) return;
|
||||
const nextSort = getNextSortState(
|
||||
config?.defaultSort || null,
|
||||
sortState || null,
|
||||
column,
|
||||
);
|
||||
onSortChange(nextSort);
|
||||
},
|
||||
[sortState, onSortChange, config?.defaultSort],
|
||||
);
|
||||
|
||||
// Track the last known defaultSort to detect changes
|
||||
const [lastDefaultSort, setLastDefaultSort] = useState(config?.defaultSort);
|
||||
|
||||
// Reset to defaultSort when it changes
|
||||
useEffect(() => {
|
||||
const currentDefaultSort = config?.defaultSort;
|
||||
|
||||
// If defaultSort changed, reset the sorting
|
||||
if (currentDefaultSort !== lastDefaultSort) {
|
||||
setLastDefaultSort(currentDefaultSort);
|
||||
|
||||
// Reset to the new default sort
|
||||
if (onSortChange) {
|
||||
onSortChange(currentDefaultSort || null);
|
||||
}
|
||||
}
|
||||
}, [config?.defaultSort, onSortChange, lastDefaultSort]);
|
||||
|
||||
// Handle empty data state
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
@@ -236,28 +379,45 @@ export const PivotTable: React.FC<PivotTableProps> = ({ data, config }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto px-5 pb-2">
|
||||
<div className="relative h-full overflow-auto px-5 pb-2">
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>Refreshing data...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-b bg-muted/50">
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow>
|
||||
{/* Dimension column header */}
|
||||
<TableHead className="p-2 text-left font-medium">
|
||||
{config?.dimensions && config.dimensions.length > 0
|
||||
? config.dimensions.map(formatColumnHeader).join(" / ") // Show all dimensions
|
||||
: "Dimension"}
|
||||
</TableHead>
|
||||
<StaticHeader
|
||||
label={
|
||||
config?.dimensions && config.dimensions.length > 0
|
||||
? config.dimensions.map(formatColumnHeader).join(" / ") // Show all dimensions
|
||||
: "Dimension"
|
||||
}
|
||||
className="p-2 text-left font-medium first:pl-2"
|
||||
/>
|
||||
|
||||
{/* Metric column headers */}
|
||||
{metrics.map((metric) => (
|
||||
<TableHead key={metric} className="p-2 text-right font-medium">
|
||||
{formatColumnHeader(metric)}
|
||||
</TableHead>
|
||||
<SortableHeader
|
||||
key={metric}
|
||||
column={metric}
|
||||
label={formatColumnHeader(metric)}
|
||||
sortState={sortState}
|
||||
onSort={handleSort}
|
||||
className="p-2 font-medium"
|
||||
rightAlign={true}
|
||||
/>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{pivotTableRows.map((row) => (
|
||||
{sortedRows.map((row) => (
|
||||
<PivotTableRowComponent key={row.id} row={row} metrics={metrics} />
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { api } from "@/src/utils/api";
|
||||
import {
|
||||
type views,
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from "@/src/features/query";
|
||||
import { type z } from "zod/v4";
|
||||
import { Chart } from "@/src/features/widgets/chart-library/Chart";
|
||||
import { type FilterState } from "@langfuse/shared";
|
||||
import { type FilterState, type OrderByState } from "@langfuse/shared";
|
||||
import { isTimeSeriesChart } from "@/src/features/widgets/chart-library/utils";
|
||||
import {
|
||||
PencilIcon,
|
||||
@@ -69,6 +69,28 @@ export function DashboardWidget({
|
||||
: new Date(new Date().getTime() - 1000);
|
||||
const toTimestamp = dateRange ? dateRange.to : new Date();
|
||||
|
||||
// Initialize sort state for pivot tables
|
||||
const defaultSort =
|
||||
widget.data?.chartConfig.type === "PIVOT_TABLE"
|
||||
? widget.data?.chartConfig.defaultSort
|
||||
: undefined;
|
||||
|
||||
const [sortState, setSortState] = useState<OrderByState | null>(() => {
|
||||
return defaultSort || null;
|
||||
});
|
||||
|
||||
// Apply defaultSort when it becomes available (after widget data loads)
|
||||
// but only if user hasn't interacted yet
|
||||
useEffect(() => {
|
||||
if (defaultSort && sortState === null) {
|
||||
setSortState(defaultSort);
|
||||
}
|
||||
}, [defaultSort, sortState]);
|
||||
|
||||
const updateSort = useCallback((newSort: OrderByState | null) => {
|
||||
setSortState(newSort);
|
||||
}, []);
|
||||
|
||||
const queryResult = api.dashboard.executeQuery.useQuery(
|
||||
{
|
||||
projectId,
|
||||
@@ -94,7 +116,15 @@ export function DashboardWidget({
|
||||
: null,
|
||||
fromTimestamp: fromTimestamp.toISOString(),
|
||||
toTimestamp: toTimestamp.toISOString(),
|
||||
orderBy: null,
|
||||
orderBy:
|
||||
widget.data?.chartConfig.type === "PIVOT_TABLE" && sortState
|
||||
? [
|
||||
{
|
||||
field: sortState.column,
|
||||
direction: sortState.order.toLowerCase() as "asc" | "desc",
|
||||
},
|
||||
]
|
||||
: null,
|
||||
chartConfig: widget.data?.chartConfig,
|
||||
},
|
||||
},
|
||||
@@ -301,6 +331,13 @@ export function DashboardWidget({
|
||||
),
|
||||
}),
|
||||
}}
|
||||
sortState={
|
||||
widget.data.chartType === "PIVOT_TABLE" ? sortState : undefined
|
||||
}
|
||||
onSortChange={
|
||||
widget.data.chartType === "PIVOT_TABLE" ? updateSort : undefined
|
||||
}
|
||||
isLoading={queryResult.isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -249,6 +249,14 @@ export function WidgetForm({
|
||||
initialValues.chartConfig?.bins ?? 10,
|
||||
);
|
||||
|
||||
// Default sort configuration for pivot tables
|
||||
const [defaultSortColumn, setDefaultSortColumn] = useState<string>(
|
||||
initialValues.chartConfig?.defaultSort?.column ?? "none",
|
||||
);
|
||||
const [defaultSortOrder, setDefaultSortOrder] = useState<"ASC" | "DESC">(
|
||||
initialValues.chartConfig?.defaultSort?.order ?? "DESC",
|
||||
);
|
||||
|
||||
// Filter state
|
||||
const { selectedOption, dateRange, setDateRangeAndOption } =
|
||||
useDashboardDateRange({ defaultRelativeAggregation: "7 days" });
|
||||
@@ -270,6 +278,17 @@ export function WidgetForm({
|
||||
}) ?? [],
|
||||
);
|
||||
|
||||
// Static sort state for pivot table preview (non-interactive)
|
||||
const previewSortState = useMemo(
|
||||
() =>
|
||||
selectedChartType === "PIVOT_TABLE" &&
|
||||
defaultSortColumn &&
|
||||
defaultSortColumn !== "none"
|
||||
? { column: defaultSortColumn, order: defaultSortOrder }
|
||||
: null,
|
||||
[selectedChartType, defaultSortColumn, defaultSortOrder],
|
||||
);
|
||||
|
||||
// Helper function to update pivot table dimensions
|
||||
const updatePivotDimension = (index: number, value: string) => {
|
||||
const newDimensions = [...pivotDimensions];
|
||||
@@ -733,7 +752,17 @@ export function WidgetForm({
|
||||
: null,
|
||||
fromTimestamp: fromTimestamp.toISOString(),
|
||||
toTimestamp: toTimestamp.toISOString(),
|
||||
orderBy: null,
|
||||
orderBy:
|
||||
selectedChartType === "PIVOT_TABLE" && previewSortState
|
||||
? [
|
||||
{
|
||||
field: previewSortState.column,
|
||||
direction: previewSortState.order.toLowerCase() as
|
||||
| "asc"
|
||||
| "desc",
|
||||
},
|
||||
]
|
||||
: null,
|
||||
chartConfig:
|
||||
selectedChartType === "HISTOGRAM"
|
||||
? { type: selectedChartType, bins: histogramBins }
|
||||
@@ -742,6 +771,13 @@ export function WidgetForm({
|
||||
type: selectedChartType,
|
||||
dimensions: pivotDimensions,
|
||||
row_limit: rowLimit,
|
||||
defaultSort:
|
||||
defaultSortColumn && defaultSortColumn !== "none"
|
||||
? {
|
||||
column: defaultSortColumn,
|
||||
order: defaultSortOrder,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: { type: selectedChartType },
|
||||
};
|
||||
@@ -757,6 +793,9 @@ export function WidgetForm({
|
||||
histogramBins,
|
||||
pivotDimensions,
|
||||
rowLimit,
|
||||
defaultSortColumn,
|
||||
defaultSortOrder,
|
||||
previewSortState,
|
||||
]);
|
||||
|
||||
const queryResult = api.dashboard.executeQuery.useQuery(
|
||||
@@ -875,6 +914,13 @@ export function WidgetForm({
|
||||
? {
|
||||
type: selectedChartType as DashboardWidgetChartType,
|
||||
row_limit: rowLimit,
|
||||
defaultSort:
|
||||
defaultSortColumn && defaultSortColumn !== "none"
|
||||
? {
|
||||
column: defaultSortColumn,
|
||||
order: defaultSortOrder,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: {
|
||||
type: selectedChartType as DashboardWidgetChartType,
|
||||
@@ -1400,6 +1446,70 @@ export function WidgetForm({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pivot Table Default Sort Configuration */}
|
||||
{selectedChartType === "PIVOT_TABLE" && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-semibold">
|
||||
Default Sort Configuration
|
||||
</h4>
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
Configure the default sort order for the pivot table. This
|
||||
will be applied when the widget is first loaded.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default-sort-column">Sort Column</Label>
|
||||
<Select
|
||||
value={defaultSortColumn}
|
||||
onValueChange={setDefaultSortColumn}
|
||||
>
|
||||
<SelectTrigger id="default-sort-column">
|
||||
<SelectValue placeholder="Select a column to sort by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No default sort</SelectItem>
|
||||
{/* Show available metrics as sort options */}
|
||||
{selectedMetrics
|
||||
.filter(
|
||||
(metric) =>
|
||||
metric.measure && metric.measure !== "",
|
||||
)
|
||||
.map((metric) => (
|
||||
<SelectItem key={metric.id} value={metric.id}>
|
||||
{formatMetricName(metric.id)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default-sort-order">Sort Order</Label>
|
||||
<Select
|
||||
value={defaultSortOrder}
|
||||
onValueChange={(value: "ASC" | "DESC") =>
|
||||
setDefaultSortOrder(value)
|
||||
}
|
||||
disabled={
|
||||
!defaultSortColumn || defaultSortColumn === "none"
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="default-sort-order">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ASC">Ascending (A-Z)</SelectItem>
|
||||
<SelectItem value="DESC">Descending (Z-A)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Visualization Section */}
|
||||
@@ -1569,6 +1679,13 @@ export function WidgetForm({
|
||||
dimensions: pivotDimensions,
|
||||
row_limit: rowLimit,
|
||||
metrics: selectedMetrics.map((metric) => metric.id), // Pass metric field names
|
||||
defaultSort:
|
||||
defaultSortColumn && defaultSortColumn !== "none"
|
||||
? {
|
||||
column: defaultSortColumn,
|
||||
order: defaultSortOrder,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: selectedChartType === "HISTOGRAM"
|
||||
? {
|
||||
@@ -1580,6 +1697,12 @@ export function WidgetForm({
|
||||
row_limit: rowLimit,
|
||||
}
|
||||
}
|
||||
sortState={
|
||||
selectedChartType === "PIVOT_TABLE"
|
||||
? previewSortState
|
||||
: undefined
|
||||
}
|
||||
onSortChange={undefined}
|
||||
/>
|
||||
) : (
|
||||
<CardContent>
|
||||
|
||||
@@ -7,6 +7,10 @@ export type WidgetChartConfig = {
|
||||
type: DashboardWidgetChartType;
|
||||
row_limit?: number;
|
||||
bins?: number;
|
||||
defaultSort?: {
|
||||
column: string;
|
||||
order: "ASC" | "DESC";
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* - Supports future expansion beyond current 2-dimension limit
|
||||
*/
|
||||
import { isNotNullOrUndefined } from "@/src/utils/types";
|
||||
import { type OrderByState } from "@langfuse/shared";
|
||||
|
||||
/**
|
||||
* Default dimension limit for pivot table data rows
|
||||
@@ -81,6 +82,8 @@ export interface PivotTableConfig {
|
||||
|
||||
/** Maximum number of data rows to display (before totals) */
|
||||
rowLimit?: number;
|
||||
|
||||
defaultSort?: OrderByState;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -608,3 +611,196 @@ export function createGrandTotalRow(
|
||||
dimensionValues,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts pivot table rows hierarchically based on sort configuration
|
||||
*
|
||||
* Hierarchical sorting behavior:
|
||||
* 1. Sort groups (subtotal rows) by their total values first
|
||||
* 2. Sort individual items within each group
|
||||
* 3. Maintain grand total at the top
|
||||
* 4. Preserve group hierarchy and indentation
|
||||
*
|
||||
* @param rows - Array of pivot table rows to sort
|
||||
* @param sortConfig - Sort configuration with column and order
|
||||
* @returns Sorted array of pivot table rows
|
||||
*/
|
||||
export function sortPivotTableRows(
|
||||
rows: PivotTableRow[],
|
||||
sortConfig: { column: string; order: "ASC" | "DESC" },
|
||||
): PivotTableRow[] {
|
||||
if (!sortConfig || !sortConfig.column) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
const { column, order } = sortConfig;
|
||||
const isAscending = order === "ASC";
|
||||
|
||||
// Helper function to get sortable value from a row
|
||||
const getSortValue = (row: PivotTableRow): number => {
|
||||
const value = row.values[column];
|
||||
if (typeof value === "number") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
// Try to parse as number, fallback to 0
|
||||
const parsed = parseFloat(value);
|
||||
return isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
// Helper function to compare two values
|
||||
const compareValues = (a: number, b: number): number => {
|
||||
if (isAscending) {
|
||||
return a - b;
|
||||
}
|
||||
return b - a;
|
||||
};
|
||||
|
||||
// Helper function to compare strings (for equal numeric values)
|
||||
const compareLabels = (a: string, b: string): number => {
|
||||
if (isAscending) {
|
||||
return a.localeCompare(b);
|
||||
}
|
||||
return b.localeCompare(a);
|
||||
};
|
||||
|
||||
// Separate different row types for processing
|
||||
const grandTotalRows = rows.filter(isTotalRow);
|
||||
const subtotalRows = rows.filter(isSubtotalRow);
|
||||
const dataRows = rows.filter(isDataRow);
|
||||
|
||||
// Sort subtotal rows (groups) by their total values
|
||||
const sortedSubtotalRows = subtotalRows.sort((a, b) => {
|
||||
const aValue = getSortValue(a);
|
||||
const bValue = getSortValue(b);
|
||||
|
||||
const valueComparison = compareValues(aValue, bValue);
|
||||
if (valueComparison !== 0) {
|
||||
return valueComparison;
|
||||
}
|
||||
|
||||
// For equal values, sort by label
|
||||
return compareLabels(a.label, b.label);
|
||||
});
|
||||
|
||||
// Group data rows by their parent subtotal
|
||||
const dataRowsByGroup: Record<string, PivotTableRow[]> = {};
|
||||
|
||||
for (const row of dataRows) {
|
||||
// Find the parent subtotal for this data row
|
||||
const parentGroup = findParentGroup(row, subtotalRows);
|
||||
const groupKey = parentGroup?.label || "ungrouped";
|
||||
|
||||
if (!dataRowsByGroup[groupKey]) {
|
||||
dataRowsByGroup[groupKey] = [];
|
||||
}
|
||||
dataRowsByGroup[groupKey].push(row);
|
||||
}
|
||||
|
||||
// Sort data rows within each group
|
||||
for (const groupKey in dataRowsByGroup) {
|
||||
dataRowsByGroup[groupKey].sort((a, b) => {
|
||||
const aValue = getSortValue(a);
|
||||
const bValue = getSortValue(b);
|
||||
|
||||
const valueComparison = compareValues(aValue, bValue);
|
||||
if (valueComparison !== 0) {
|
||||
return valueComparison;
|
||||
}
|
||||
|
||||
// For equal values, sort by label
|
||||
return compareLabels(a.label, b.label);
|
||||
});
|
||||
}
|
||||
|
||||
// Reconstruct the sorted rows maintaining hierarchy
|
||||
const sortedRows: PivotTableRow[] = [];
|
||||
|
||||
// Add grand total at the top
|
||||
sortedRows.push(...grandTotalRows);
|
||||
|
||||
// Add sorted subtotal rows with their sorted data rows
|
||||
for (const subtotalRow of sortedSubtotalRows) {
|
||||
sortedRows.push(subtotalRow);
|
||||
|
||||
// Add sorted data rows for this group
|
||||
const groupKey = subtotalRow.label;
|
||||
const groupDataRows = dataRowsByGroup[groupKey] || [];
|
||||
sortedRows.push(...groupDataRows);
|
||||
}
|
||||
|
||||
// Add any ungrouped data rows at the end
|
||||
const ungroupedRows = dataRowsByGroup["ungrouped"] || [];
|
||||
sortedRows.push(...ungroupedRows);
|
||||
|
||||
return sortedRows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the parent subtotal group for a data row
|
||||
*
|
||||
* @param dataRow - The data row to find parent for
|
||||
* @param subtotalRows - Array of all subtotal rows
|
||||
* @returns The parent subtotal row or null if not found
|
||||
*/
|
||||
function findParentGroup(
|
||||
dataRow: PivotTableRow,
|
||||
subtotalRows: PivotTableRow[],
|
||||
): PivotTableRow | null {
|
||||
// For now, use a simple approach: find subtotal row with matching level
|
||||
// This can be enhanced later for more complex grouping logic
|
||||
const parentLevel = dataRow.level - 1;
|
||||
|
||||
return (
|
||||
subtotalRows.find(
|
||||
(subtotal) =>
|
||||
subtotal.level === parentLevel &&
|
||||
subtotal.label.includes(dataRow.label.split(" - ")[0]), // Simple matching
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the next sort state in the simple cycle: DESC → ASC → unsorted
|
||||
*
|
||||
* @param defaultSort - Default sort state (OrderByState or null for unsorted)
|
||||
* @param currentSort - Current sort state (OrderByState or null for unsorted)
|
||||
* @param column - Column to sort by
|
||||
* @returns Next sort state in the cycle
|
||||
*/
|
||||
export function getNextSortState(
|
||||
defaultSort: OrderByState,
|
||||
currentSort: OrderByState,
|
||||
column: string,
|
||||
): OrderByState | null {
|
||||
// Different column or no current sort → start with DESC
|
||||
if (!currentSort || currentSort.column !== column) {
|
||||
return { column, order: "DESC" };
|
||||
}
|
||||
|
||||
// Same column: DESC → ASC → null (unsorted)
|
||||
if (currentSort.order === "DESC") {
|
||||
return { column, order: "ASC" };
|
||||
}
|
||||
|
||||
// Column other than the default column, go back to default
|
||||
if (
|
||||
currentSort.order === "ASC" &&
|
||||
currentSort.column !== defaultSort?.column
|
||||
) {
|
||||
return defaultSort || null;
|
||||
}
|
||||
|
||||
// Default column, flip back to DESC
|
||||
if (
|
||||
currentSort.order === "ASC" &&
|
||||
currentSort.column === defaultSort?.column
|
||||
) {
|
||||
return { column, order: "DESC" };
|
||||
}
|
||||
|
||||
// Fallback (shouldn't happen)
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
QueueName,
|
||||
getQueue,
|
||||
IngestionQueue,
|
||||
TraceUpsertQueue,
|
||||
IngestionEvent,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { AdminApiAuthService } from "@/src/ee/features/admin-api/server/adminApiAuth";
|
||||
@@ -55,24 +56,26 @@ 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());
|
||||
queues.push(...TraceUpsertQueue.getShardNames());
|
||||
const queueCounts = await Promise.all(
|
||||
queues.map(async (queueName) => {
|
||||
try {
|
||||
const queue = queueName.startsWith(QueueName.IngestionQueue)
|
||||
? IngestionQueue.getInstance({ shardName: queueName })
|
||||
: getQueue(
|
||||
queueName as Exclude<QueueName, QueueName.IngestionQueue>,
|
||||
);
|
||||
let queue;
|
||||
if (queueName.startsWith(QueueName.IngestionQueue)) {
|
||||
queue = IngestionQueue.getInstance({ shardName: queueName });
|
||||
} else if (queueName.startsWith(QueueName.TraceUpsert)) {
|
||||
queue = TraceUpsertQueue.getInstance({ shardName: queueName });
|
||||
} else {
|
||||
queue = getQueue(
|
||||
queueName as Exclude<
|
||||
QueueName,
|
||||
QueueName.IngestionQueue | QueueName.TraceUpsert
|
||||
>,
|
||||
);
|
||||
}
|
||||
const jobCount = await queue?.getJobCounts();
|
||||
return { queueName, jobCount };
|
||||
} catch (e) {
|
||||
@@ -84,15 +87,32 @@ 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(", ")}`,
|
||||
);
|
||||
|
||||
for (const queueName of body.data.queueNames) {
|
||||
const queue = queueName.startsWith(QueueName.IngestionQueue)
|
||||
? IngestionQueue.getInstance({ shardName: queueName })
|
||||
: getQueue(queueName as Exclude<QueueName, QueueName.IngestionQueue>);
|
||||
let queue;
|
||||
if (queueName.startsWith(QueueName.IngestionQueue)) {
|
||||
queue = IngestionQueue.getInstance({ shardName: queueName });
|
||||
} else if (queueName.startsWith(QueueName.TraceUpsert)) {
|
||||
queue = TraceUpsertQueue.getInstance({ shardName: queueName });
|
||||
} else {
|
||||
queue = getQueue(
|
||||
queueName as Exclude<
|
||||
QueueName,
|
||||
QueueName.IngestionQueue | QueueName.TraceUpsert
|
||||
>,
|
||||
);
|
||||
}
|
||||
|
||||
let totalCount = 0;
|
||||
let failedCountInLoop;
|
||||
@@ -127,9 +147,19 @@ export default async function handler(
|
||||
);
|
||||
|
||||
for (const queueName of body.data.queueNames) {
|
||||
const queue = queueName.startsWith(QueueName.IngestionQueue)
|
||||
? IngestionQueue.getInstance({ shardName: queueName })
|
||||
: getQueue(queueName as Exclude<QueueName, QueueName.IngestionQueue>);
|
||||
let queue;
|
||||
if (queueName.startsWith(QueueName.IngestionQueue)) {
|
||||
queue = IngestionQueue.getInstance({ shardName: queueName });
|
||||
} else if (queueName.startsWith(QueueName.TraceUpsert)) {
|
||||
queue = TraceUpsertQueue.getInstance({ shardName: queueName });
|
||||
} else {
|
||||
queue = getQueue(
|
||||
queueName as Exclude<
|
||||
QueueName,
|
||||
QueueName.IngestionQueue | QueueName.TraceUpsert
|
||||
>,
|
||||
);
|
||||
}
|
||||
const jobCount = await queue?.getJobCounts("failed");
|
||||
logger.info(
|
||||
`Retrying ${JSON.stringify(jobCount)} jobs for queue ${queueName}`,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -1,93 +1,16 @@
|
||||
// This page is currently only shown to Langfuse cloud users.
|
||||
// It might be expanded to everyone in the future when it does not only ask for the referral source.
|
||||
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod/v4";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/src/components/ui/form";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { useRouter } from "next/router";
|
||||
import { LangfuseIcon } from "@/src/components/LangfuseLogo";
|
||||
import { Textarea } from "@/src/components/ui/textarea";
|
||||
|
||||
const referralSourceSchema = z.object({
|
||||
referralSource: z.string().optional(),
|
||||
});
|
||||
|
||||
export default function ReferralSource() {
|
||||
const posthog = usePostHog();
|
||||
const router = useRouter();
|
||||
const form = useForm({
|
||||
resolver: zodResolver(referralSourceSchema),
|
||||
defaultValues: {
|
||||
referralSource: "",
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(values: z.infer<typeof referralSourceSchema>) {
|
||||
if (values.referralSource && values.referralSource !== "") {
|
||||
posthog.capture("survey sent", {
|
||||
$survey_id: "018ade05-4d8c-0000-36b7-fc390b221590",
|
||||
$survey_name: "Referral source",
|
||||
$survey_response: values.referralSource,
|
||||
});
|
||||
}
|
||||
void router.push("/");
|
||||
}
|
||||
import Head from "next/head";
|
||||
import { OnboardingSurvey } from "@/src/features/onboarding/components/OnboardingSurvey";
|
||||
|
||||
export default function OnboardingPage() {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col py-6 sm:min-h-full sm:justify-center sm:px-6 sm:py-12 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<LangfuseIcon className="mx-auto" />
|
||||
<h2 className="mt-4 text-center text-2xl font-bold leading-9 tracking-tight text-primary">
|
||||
Welcome to Langfuse
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mt-14 bg-background px-6 py-10 shadow sm:mx-auto sm:w-full sm:max-w-[480px] sm:rounded-lg sm:px-12">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-6"
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="referralSource"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Where did you hear about us?{" "}
|
||||
<span className="font-normal">(optional)</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="We're curious to know how you discovered the project! Thanks for sharing."
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant={form.formState.isDirty ? "default" : "secondary"}
|
||||
className="w-full"
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isDirty ? "Continue" : "Skip"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<Head>
|
||||
<title>Onboarding | Langfuse</title>
|
||||
</Head>
|
||||
<OnboardingSurvey />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import { defaultEvalModelRouter } from "@/src/features/evals/server/defaultEvalM
|
||||
import { plainRouter } from "@/src/features/support-chat/trpc/plain";
|
||||
import { slackRouter } from "@/src/features/slack/server/router";
|
||||
import { queueAssignmentRouter } from "@/src/features/annotation-queues/server/annotationQueueAssignments";
|
||||
import { surveysRouter } from "@/src/server/api/routers/surveys";
|
||||
|
||||
/**
|
||||
* This is the primary router for your server.
|
||||
@@ -94,6 +95,7 @@ export const appRouter = createTRPCRouter({
|
||||
automations: automationsRouter,
|
||||
slack: slackRouter,
|
||||
plain: plainRouter,
|
||||
surveys: surveysRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { z } from "zod/v4";
|
||||
import { createTRPCRouter, protectedProcedure } from "@/src/server/api/trpc";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { SurveyName } from "@prisma/client";
|
||||
import { logger } from "@langfuse/shared/src/server";
|
||||
|
||||
const surveyResponseSchema = z.object({
|
||||
surveyName: z.nativeEnum(SurveyName),
|
||||
response: z.record(z.string(), z.string()),
|
||||
orgId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const surveysRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
.input(surveyResponseSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
const survey = await ctx.prisma.survey.create({
|
||||
data: {
|
||||
surveyName: input.surveyName,
|
||||
response: input.response,
|
||||
userId: ctx.session.user.id,
|
||||
userEmail: ctx.session.user.email ?? undefined,
|
||||
orgId: input.orgId,
|
||||
},
|
||||
});
|
||||
|
||||
return survey;
|
||||
} catch (error) {
|
||||
logger.error("Failed to call surveys.create", error);
|
||||
if (error instanceof TRPCError) {
|
||||
throw error;
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to save survey response",
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "worker",
|
||||
"version": "3.95.2",
|
||||
"version": "3.97.3",
|
||||
"description": "",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
@@ -38,7 +38,6 @@
|
||||
"@opentelemetry/resources": "^1.26.0",
|
||||
"@opentelemetry/sdk-node": "^0.53.0",
|
||||
"@prisma/instrumentation": "^6.3.0",
|
||||
"@slack/web-api": "^7.0.0",
|
||||
"backoff": "^2.5.0",
|
||||
"bullmq": "^5.34.10",
|
||||
"cors": "^2.8.5",
|
||||
|
||||
@@ -13,9 +13,14 @@ describe.sequential("handle redis events", () => {
|
||||
});
|
||||
|
||||
test("handle redis job succeeding", async () => {
|
||||
WorkerManager.register(QueueName.TraceUpsert, async () => true);
|
||||
const shardingKey = "project-id-trace-id";
|
||||
const traceUpsertQueue = TraceUpsertQueue.getInstance({ shardingKey });
|
||||
const shardNames = TraceUpsertQueue.getShardNames();
|
||||
|
||||
const traceUpsertQueue = TraceUpsertQueue.getInstance();
|
||||
// Register workers for all shards since we don't know which one will be used
|
||||
shardNames.forEach((shardName) => {
|
||||
WorkerManager.register(shardName as QueueName, async () => true);
|
||||
});
|
||||
|
||||
expect(traceUpsertQueue).toBeDefined();
|
||||
|
||||
@@ -44,7 +49,8 @@ describe.sequential("handle redis events", () => {
|
||||
// IngestionQueue worker vs TraceUpsert producer
|
||||
WorkerManager.register(QueueName.IngestionQueue, async () => true);
|
||||
|
||||
const traceUpsertQueue = TraceUpsertQueue.getInstance();
|
||||
const shardingKey = "project-id-trace-id";
|
||||
const traceUpsertQueue = TraceUpsertQueue.getInstance({ shardingKey });
|
||||
|
||||
expect(traceUpsertQueue).toBeDefined();
|
||||
|
||||
|
||||
+12
-7
@@ -29,6 +29,7 @@ import {
|
||||
BlobStorageIntegrationQueue,
|
||||
DeadLetterRetryQueue,
|
||||
IngestionQueue,
|
||||
TraceUpsertQueue,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { env } from "./env";
|
||||
import { ingestionQueueProcessorBuilder } from "./queues/ingestionQueue";
|
||||
@@ -90,13 +91,17 @@ ClickhouseReadSkipCache.getInstance(prisma)
|
||||
});
|
||||
|
||||
if (env.QUEUE_CONSUMER_TRACE_UPSERT_QUEUE_IS_ENABLED === "true") {
|
||||
WorkerManager.register(
|
||||
QueueName.TraceUpsert,
|
||||
evalJobTraceCreatorQueueProcessor,
|
||||
{
|
||||
concurrency: env.LANGFUSE_TRACE_UPSERT_WORKER_CONCURRENCY,
|
||||
},
|
||||
);
|
||||
// Register workers for all trace upsert queue shards
|
||||
const traceUpsertShardNames = TraceUpsertQueue.getShardNames();
|
||||
traceUpsertShardNames.forEach((shardName) => {
|
||||
WorkerManager.register(
|
||||
shardName as QueueName,
|
||||
evalJobTraceCreatorQueueProcessor,
|
||||
{
|
||||
concurrency: env.LANGFUSE_TRACE_UPSERT_WORKER_CONCURRENCY,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (env.QUEUE_CONSUMER_CREATE_EVAL_QUEUE_IS_ENABLED === "true") {
|
||||
|
||||
@@ -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.95.2";
|
||||
export const VERSION = "v3.97.3";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -242,6 +242,9 @@ const EnvSchema = z.object({
|
||||
.positive()
|
||||
.default(120_000), // 2 minutes
|
||||
|
||||
LANGFUSE_EXPERIMENT_INSERT_INTO_TRACES_TABLE: z
|
||||
.enum(["true", "false"])
|
||||
.default("true"),
|
||||
LANGFUSE_EXPERIMENT_INSERT_INTO_AGGREGATING_MERGE_TREES: z
|
||||
.enum(["true", "false"])
|
||||
.default("false"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -138,10 +138,12 @@ export const ingestionQueueProcessorBuilder = (
|
||||
const events: IngestionEventType[] = [];
|
||||
|
||||
// Check if we should skip S3 list operation
|
||||
// The producer sets skipS3List to true if it's an OTel observation
|
||||
const shouldSkipS3List =
|
||||
job.data.payload.data.skipS3List && job.data.payload.data.fileKey;
|
||||
|
||||
// The producer sets skipS3List to true if it's an OTel observation
|
||||
(job.data.payload.data.skipS3List && job.data.payload.data.fileKey) ||
|
||||
// If we do not insert into the traces table, we can skip the list and process single files
|
||||
(env.LANGFUSE_EXPERIMENT_INSERT_INTO_TRACES_TABLE === "false" &&
|
||||
clickhouseEntityType === "trace");
|
||||
const s3Prefix = `${env.LANGFUSE_S3_EVENT_UPLOAD_PREFIX}${job.data.payload.authCheck.scope.projectId}/${clickhouseEntityType}/${job.data.payload.data.eventBodyId}/`;
|
||||
|
||||
let totalS3DownloadSizeBytes = 0;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
logger,
|
||||
QueueName,
|
||||
IngestionQueue,
|
||||
TraceUpsertQueue,
|
||||
recordGauge,
|
||||
recordHistogram,
|
||||
recordIncrement,
|
||||
@@ -35,7 +36,14 @@ export class WorkerManager {
|
||||
const result = await processor(job);
|
||||
const queue = queueName.startsWith(QueueName.IngestionQueue)
|
||||
? IngestionQueue.getInstance({ shardName: queueName })
|
||||
: getQueue(queueName as Exclude<QueueName, QueueName.IngestionQueue>);
|
||||
: queueName.startsWith(QueueName.TraceUpsert)
|
||||
? TraceUpsertQueue.getInstance({ shardName: queueName })
|
||||
: getQueue(
|
||||
queueName as Exclude<
|
||||
QueueName,
|
||||
QueueName.IngestionQueue | QueueName.TraceUpsert
|
||||
>,
|
||||
);
|
||||
Promise.allSettled([
|
||||
// Here we only consider waiting jobs instead of the default ("waiting" or "delayed"
|
||||
// or "prioritized" or "waiting-children") that count provides
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -371,75 +371,59 @@ export class IngestionService {
|
||||
traceEventList: timeSortedEvents,
|
||||
});
|
||||
|
||||
const minTimestamp = Math.min(
|
||||
...timeSortedEvents.flatMap((e) =>
|
||||
e.body?.timestamp ? [new Date(e.body.timestamp).getTime()] : [],
|
||||
),
|
||||
);
|
||||
const timestamp =
|
||||
minTimestamp === Infinity
|
||||
? undefined
|
||||
: convertDateToClickhouseDateTime(new Date(minTimestamp));
|
||||
const clickhouseTraceRecord = await this.getClickhouseRecord({
|
||||
projectId,
|
||||
entityId,
|
||||
table: TableName.Traces,
|
||||
additionalFilters: {
|
||||
whereCondition: timestamp
|
||||
? " AND timestamp >= {timestamp: DateTime64(3)} "
|
||||
: "",
|
||||
params: { timestamp },
|
||||
},
|
||||
});
|
||||
|
||||
if (clickhouseTraceRecord) {
|
||||
recordIncrement("langfuse.ingestion.lookup.hit", 1, {
|
||||
store: "clickhouse",
|
||||
object: "trace",
|
||||
});
|
||||
}
|
||||
|
||||
const finalTraceRecord = await this.mergeTraceRecords({
|
||||
clickhouseTraceRecord,
|
||||
traceRecords,
|
||||
});
|
||||
finalTraceRecord.created_at =
|
||||
clickhouseTraceRecord?.created_at ?? createdAtTimestamp.getTime();
|
||||
|
||||
// Search for the first non-null input and output in the trace events and set them on the merged result.
|
||||
// Fallback to the ClickHouse input/output if none are found within the events list.
|
||||
const reversedRawRecords = timeSortedEvents.slice().reverse();
|
||||
finalTraceRecord.input = this.stringify(
|
||||
reversedRawRecords.find((record) => record?.body?.input)?.body?.input ??
|
||||
clickhouseTraceRecord?.input,
|
||||
);
|
||||
finalTraceRecord.output = this.stringify(
|
||||
reversedRawRecords.find((record) => record?.body?.output)?.body?.output ??
|
||||
clickhouseTraceRecord?.output,
|
||||
);
|
||||
const finalIO = {
|
||||
input: this.stringify(
|
||||
reversedRawRecords.find((record) => record?.body?.input)?.body?.input,
|
||||
),
|
||||
output: this.stringify(
|
||||
reversedRawRecords.find((record) => record?.body?.output)?.body?.output,
|
||||
),
|
||||
};
|
||||
|
||||
// If the trace has a sessionId, we upsert the corresponding session into Postgres.
|
||||
if (finalTraceRecord.session_id) {
|
||||
try {
|
||||
await this.prisma.$executeRaw`
|
||||
INSERT INTO trace_sessions (id, project_id, environment, created_at, updated_at)
|
||||
VALUES (${finalTraceRecord.session_id}, ${projectId}, ${finalTraceRecord.environment}, NOW(), NOW())
|
||||
ON CONFLICT (id, project_id)
|
||||
DO UPDATE SET
|
||||
environment = EXCLUDED.environment,
|
||||
updated_at = NOW()
|
||||
WHERE trace_sessions.environment IS DISTINCT FROM EXCLUDED.environment
|
||||
`;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Failed to upsert session ${finalTraceRecord.session_id}`,
|
||||
e,
|
||||
);
|
||||
throw e;
|
||||
if (env.LANGFUSE_EXPERIMENT_INSERT_INTO_TRACES_TABLE === "true") {
|
||||
const minTimestamp = Math.min(
|
||||
...timeSortedEvents.flatMap((e) =>
|
||||
e.body?.timestamp ? [new Date(e.body.timestamp).getTime()] : [],
|
||||
),
|
||||
);
|
||||
const timestamp =
|
||||
minTimestamp === Infinity
|
||||
? undefined
|
||||
: convertDateToClickhouseDateTime(new Date(minTimestamp));
|
||||
const clickhouseTraceRecord = await this.getClickhouseRecord({
|
||||
projectId,
|
||||
entityId,
|
||||
table: TableName.Traces,
|
||||
additionalFilters: {
|
||||
whereCondition: timestamp
|
||||
? " AND timestamp >= {timestamp: DateTime64(3)} "
|
||||
: "",
|
||||
params: { timestamp },
|
||||
},
|
||||
});
|
||||
|
||||
if (clickhouseTraceRecord) {
|
||||
recordIncrement("langfuse.ingestion.lookup.hit", 1, {
|
||||
store: "clickhouse",
|
||||
object: "trace",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.clickHouseWriter.addToQueue(TableName.Traces, finalTraceRecord);
|
||||
const finalTraceRecord = await this.mergeTraceRecords({
|
||||
clickhouseTraceRecord,
|
||||
traceRecords,
|
||||
});
|
||||
finalTraceRecord.created_at =
|
||||
clickhouseTraceRecord?.created_at ?? createdAtTimestamp.getTime();
|
||||
|
||||
finalTraceRecord.input = finalIO.input ?? clickhouseTraceRecord?.input;
|
||||
finalTraceRecord.output = finalIO.output ?? clickhouseTraceRecord?.output;
|
||||
|
||||
this.clickHouseWriter.addToQueue(TableName.Traces, finalTraceRecord);
|
||||
}
|
||||
|
||||
// Experimental: Also write to traces_null table if experiment flag is enabled
|
||||
// Here we use the raw events to ensure that we stop relying on the merge logic
|
||||
@@ -450,22 +434,48 @@ export class IngestionService {
|
||||
this.clickHouseWriter.addToQueue(TableName.TracesNull, {
|
||||
...r,
|
||||
// We need to re-add input and output here as they were excluded in a previous mapping step
|
||||
input: finalTraceRecord.input ?? "",
|
||||
output: finalTraceRecord.output ?? "",
|
||||
input: finalIO.input ?? "",
|
||||
output: finalIO.output ?? "",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// If the trace has a sessionId, we upsert the corresponding session into Postgres.
|
||||
const traceRecordWithSession = traceRecords
|
||||
.slice()
|
||||
.reverse()
|
||||
.find((t) => t.session_id);
|
||||
if (traceRecordWithSession) {
|
||||
try {
|
||||
await this.prisma.$executeRaw`
|
||||
INSERT INTO trace_sessions (id, project_id, environment, created_at, updated_at)
|
||||
VALUES (${traceRecordWithSession.session_id}, ${projectId}, ${traceRecordWithSession.environment}, NOW(), NOW())
|
||||
ON CONFLICT (id, project_id)
|
||||
DO UPDATE SET
|
||||
environment = EXCLUDED.environment,
|
||||
updated_at = NOW()
|
||||
WHERE trace_sessions.environment IS DISTINCT FROM EXCLUDED.environment
|
||||
`;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Failed to upsert session ${traceRecordWithSession.session_id}`,
|
||||
e,
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Add trace into trace upsert queue for eval processing
|
||||
const traceUpsertQueue = TraceUpsertQueue.getInstance();
|
||||
const shardingKey = `${projectId}-${entityId}`;
|
||||
const traceUpsertQueue = TraceUpsertQueue.getInstance({ shardingKey });
|
||||
if (!traceUpsertQueue) {
|
||||
logger.error("TraceUpsertQueue is not initialized");
|
||||
return;
|
||||
}
|
||||
await traceUpsertQueue.add(QueueJobs.TraceUpsert, {
|
||||
payload: {
|
||||
projectId: finalTraceRecord.project_id,
|
||||
traceId: finalTraceRecord.id,
|
||||
projectId,
|
||||
traceId: entityId,
|
||||
},
|
||||
id: randomUUID(),
|
||||
timestamp: new Date(),
|
||||
|
||||
Reference in New Issue
Block a user