Compare commits

...
7 Commits
Author SHA1 Message Date
steffen911 daf4e2fe02 chore: release v3.97.3 2025-08-13 11:38:24 +02:00
Steffen SchmitzandGitHub 129693fb69 chore: skip body validation for bullmq GET requests (#8490) 2025-08-13 09:15:33 +00:00
Steffen SchmitzandGitHub 204948db44 perf: skip FINAL for traces all route without metrics (#8489) 2025-08-13 09:15:29 +00:00
marliessophieandGitHub d42ba5fc99 fix(evals): redirect and pull data for latest template version post template edit (#8491) 2025-08-13 09:15:25 +00:00
Steffen SchmitzandGitHub 97a539ced3 chore: allow whitelisting which AMTs can be used (#8488) 2025-08-13 09:12:07 +00:00
Leo WeigandandGitHub 9d8dace197 feat(llm-connections): populate gcp region in update form (#8487)
feat(llm-connections): correctly show gcp region in update form
2025-08-13 08:58:31 +00:00
marliessophieandGitHub bc2dc4d89c feat(annotation): add POST queue API (#8478)
* feat(annotation): add POST queue API

* chore: fix types

* docs: add API ref

* chore: improve score configs check

* chore: add postman collection
2025-08-12 17:25:19 +00:00
19 changed files with 358 additions and 42 deletions
@@ -22,6 +22,13 @@ service:
docs: limit of items per page
response: PaginatedAnnotationQueues
createQueue:
docs: Create an annotation queue
method: POST
path: /annotation-queues
request: CreateAnnotationQueueRequest
response: AnnotationQueue
getQueue:
docs: Get an annotation queue by ID
method: GET
@@ -168,6 +175,12 @@ types:
data: list<AnnotationQueueItem>
meta: pagination.MetaResponse
CreateAnnotationQueueRequest:
properties:
name: string
description: optional<string>
scoreConfigIds: list<string>
CreateAnnotationQueueItemRequest:
properties:
objectId: string
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "3.97.2",
"version": "3.97.3",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
+6
View File
@@ -158,6 +158,12 @@ const EnvSchema = z.object({
LANGFUSE_EXPERIMENT_INSERT_INTO_AGGREGATING_MERGE_TREES: z
.enum(["true", "false"])
.default("false"),
LANGFUSE_EXPERIMENT_WHITELISTED_AMT_TABLES: z
.string()
.optional()
.transform((s) =>
s ? s.split(",").map((s) => s.toLowerCase().trim()) : [],
),
LANGFUSE_INGESTION_PROCESSING_SAMPLED_PROJECTS: z
.string()
.optional()
@@ -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;
};
/**
@@ -15,6 +15,7 @@ export function createBasicAuthHeader(
export type CreateOrgProjectAndApiKeyOptions = {
projectId?: string;
plan?: "Team" | "Hobby" | "Core" | "Pro" | "Enterprise";
};
export const createOrgProjectAndApiKey = async (
props?: CreateOrgProjectAndApiKeyOptions,
@@ -25,7 +26,7 @@ export const createOrgProjectAndApiKey = async (
id: v4(),
name: v4(),
cloudConfig: CloudConfigSchema.parse({
plan: "Team",
plan: props?.plan ?? "Team",
}),
},
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "3.97.2",
"version": "3.97.3",
"private": true,
"license": "MIT",
"engines": {
+63 -1
View File
@@ -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(
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.97.2";
export const VERSION = "v3.97.3";
@@ -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)
@@ -289,6 +289,7 @@ export const llmApiKeyRouter = createTRPCRouter({
customModels: true,
withDefaultModels: true,
extraHeaderKeys: true,
config: true,
},
where: {
projectId: input.projectId,
+3 -4
View File
@@ -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}` : ""}
@@ -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({
+7 -7
View File
@@ -56,13 +56,6 @@ export default async function handler(
return;
}
const body = ManageBullBody.safeParse(req.body);
if (!body.success) {
res.status(400).json({ error: body.error });
return;
}
if (req.method === "GET") {
const queues: string[] = Object.values(QueueName);
queues.push(...IngestionQueue.getShardNames());
@@ -94,6 +87,13 @@ export default async function handler(
return res.status(200).json(queueCounts);
}
const body = ManageBullBody.safeParse(req.body);
if (!body.success) {
res.status(400).json({ error: body.error });
return;
}
if (req.method === "POST" && body.data.action === "remove") {
logger.info(
`Removing jobs for queues ${body.data.queueNames.join(", ")}`,
@@ -2,12 +2,14 @@ import { prisma } from "@langfuse/shared/src/db";
import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares";
import { createAuthedProjectAPIRoute } from "@/src/features/public-api/server/createAuthedProjectAPIRoute";
import {
CreateAnnotationQueueBody,
CreateAnnotationQueueResponse,
GetAnnotationQueuesQuery,
GetAnnotationQueuesResponse,
} from "@/src/features/public-api/types/annotation-queues";
import { InvalidRequestError, MethodNotAllowedError } from "@langfuse/shared";
export default withMiddlewares({
// NOTE: Post API requires entitlement check
GET: createAuthedProjectAPIRoute({
name: "Get annotation queues",
querySchema: GetAnnotationQueuesQuery,
@@ -54,4 +56,72 @@ export default withMiddlewares({
};
},
}),
POST: createAuthedProjectAPIRoute({
name: "Create annotation queue",
bodySchema: CreateAnnotationQueueBody,
responseSchema: CreateAnnotationQueueResponse,
fn: async ({ body, auth }) => {
// entitlement check
if (auth.scope.plan === "cloud:hobby") {
if (
(await prisma.annotationQueue.count({
where: {
projectId: auth.scope.projectId,
},
})) >= 1
) {
throw new MethodNotAllowedError(
"Maximum number of annotation queues reached on Hobby plan.",
);
}
}
const existingQueue = await prisma.annotationQueue.findFirst({
where: {
projectId: auth.scope.projectId,
name: body.name,
},
});
if (existingQueue) {
throw new InvalidRequestError("A queue with this name already exists.");
}
// verify the score configs exist
const scoreConfigs = await prisma.scoreConfig.findMany({
where: {
id: { in: body.scoreConfigIds },
projectId: auth.scope.projectId,
},
select: {
id: true,
},
});
const scoreConfigIdSet = new Set(scoreConfigs.map((config) => config.id));
if (body.scoreConfigIds.some((id) => !scoreConfigIdSet.has(id))) {
throw new InvalidRequestError(
"At least one of the score config IDs cannot be found for the given project.",
);
}
const queue = await prisma.annotationQueue.create({
data: {
projectId: auth.scope.projectId,
name: body.name,
description: body.description,
scoreConfigIds: body.scoreConfigIds,
},
});
return {
id: queue.id,
name: queue.name,
description: queue.description,
scoreConfigIds: queue.scoreConfigIds,
createdAt: queue.createdAt,
updatedAt: queue.updatedAt,
};
},
}),
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "worker",
"version": "3.97.2",
"version": "3.97.3",
"description": "",
"license": "MIT",
"private": true,
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.97.2";
export const VERSION = "v3.97.3";