Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd632fea9e | ||
|
|
7527bb0d84 | ||
|
|
8cc4a5537f | ||
|
|
0a6d3f108a | ||
|
|
1bf83313e3 | ||
|
|
9c3a715d77 | ||
|
|
0cf2a33473 | ||
|
|
51554eb066 | ||
|
|
cbc21bb9cc | ||
|
|
8e30694214 | ||
|
|
0d20d9de2b |
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse",
|
||||
"version": "3.166.0",
|
||||
"version": "3.167.0",
|
||||
"author": "engineering@langfuse.com",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
|
||||
@@ -97,6 +97,15 @@ the same PR.
|
||||
2. Update ClickHouse query/mapping logic in `src/server/clickhouse/*` and
|
||||
related repositories.
|
||||
3. Validate ingestion/read path impact in both `web` and `worker`.
|
||||
4. If the change affects columns, types, or nullability of tables read by blob
|
||||
storage export queries (`getTracesForBlobStorageExport`,
|
||||
`getObservationsForBlobStorageExport`, `getScoresForBlobStorageExport`,
|
||||
`getEventsForBlobStorageExport`, or the EventsQueryBuilder `export` field
|
||||
set), fetch the latest published docs and check for discrepancies:
|
||||
- https://langfuse.com/docs/api-and-data-platform/features/export-to-blob-storage
|
||||
- https://langfuse.com/docs/api-and-data-platform/features/blob-storage-export-fields
|
||||
Surface any mismatches in field names, types, nullability, or filter
|
||||
descriptions so they can be addressed in the docs repo.
|
||||
|
||||
### Queue payload contract change
|
||||
|
||||
@@ -130,3 +139,8 @@ the same PR.
|
||||
- Do not hand-edit generated artifacts under `prisma/generated/*` or `dist/*`.
|
||||
- Avoid exposing server-only modules through `src/index.ts` if they must remain
|
||||
frontend-safe.
|
||||
- Changes to domain constants consumed by blob storage exports (e.g.
|
||||
`LISTABLE_SCORE_TYPES` in `src/domain/scores.ts`, score data type enums)
|
||||
should be reviewed against the blob storage export field reference docs for
|
||||
consistency — fetch the latest page and surface any discrepancies:
|
||||
https://langfuse.com/docs/api-and-data-platform/features/blob-storage-export-fields
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const VERSION = "v3.166.0";
|
||||
export const VERSION = "v3.167.0";
|
||||
|
||||
+3
-3
@@ -17,7 +17,7 @@ FROM --platform=${TARGETPLATFORM:-linux/amd64} alpine AS runtime-base
|
||||
# Remove build-only package managers. npm stays here because the runner stage
|
||||
# uses it to install prisma and optional dd-trace before removing it.
|
||||
RUN rm -rf /usr/local/lib/node_modules/corepack && \
|
||||
rm -f /usr/local/bin/corepack /usr/local/bin/pnpm /usr/local/bin/pnpx /usr/local/bin/yarn /usr/local/bin/yarnpkg
|
||||
rm -f /usr/local/bin/corepack /usr/local/bin/yarn /usr/local/bin/yarnpkg
|
||||
|
||||
FROM --platform=${TARGETPLATFORM:-linux/amd64} build-base AS pruner
|
||||
|
||||
@@ -122,7 +122,7 @@ ARG GID=1001
|
||||
RUN addgroup --system --gid ${GID} nodejs
|
||||
RUN adduser --system --uid ${UID} nextjs
|
||||
|
||||
RUN npm install -g --no-package-lock --no-save prisma@6.17.1
|
||||
RUN npm install -g --no-package-lock --no-save prisma@6.19.3
|
||||
|
||||
# Install dd-trace only if NEXT_PUBLIC_LANGFUSE_CLOUD_REGION is configured
|
||||
ARG NEXT_PUBLIC_LANGFUSE_CLOUD_REGION
|
||||
@@ -130,7 +130,7 @@ RUN if [ -n "$NEXT_PUBLIC_LANGFUSE_CLOUD_REGION" ]; then \
|
||||
npm install --no-package-lock --no-save dd-trace@5.65.0; \
|
||||
fi
|
||||
|
||||
# Runtime images do not need npm once explicit runtime tools are installed.
|
||||
# npm is only used for the installs above; remove it from the final runtime image.
|
||||
RUN rm -rf /usr/local/lib/node_modules/npm && \
|
||||
rm -f /usr/local/bin/npm /usr/local/bin/npx
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "3.166.0",
|
||||
"version": "3.167.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
jest.mock("@langfuse/shared/src/server", () => {
|
||||
const actual = jest.requireActual("@langfuse/shared/src/server");
|
||||
return {
|
||||
...actual,
|
||||
fetchLLMCompletion: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import type { Session } from "next-auth";
|
||||
import { LLMAdapter } from "@langfuse/shared";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { appRouter } from "@/src/server/api/root";
|
||||
import { createInnerTRPCContext } from "@/src/server/api/trpc";
|
||||
import { decrypt } from "@langfuse/shared/encryption";
|
||||
import { createOrgProjectAndApiKey } from "@langfuse/shared/src/server";
|
||||
import {
|
||||
createOrgProjectAndApiKey,
|
||||
fetchLLMCompletion,
|
||||
} from "@langfuse/shared/src/server";
|
||||
|
||||
const mockFetchLLMCompletion = jest.mocked(fetchLLMCompletion);
|
||||
|
||||
describe("llmApiKey.all RPC", () => {
|
||||
let projectId: string;
|
||||
@@ -49,6 +62,7 @@ describe("llmApiKey.all RPC", () => {
|
||||
const setup = await createOrgProjectAndApiKey();
|
||||
projectId = setup.projectId;
|
||||
orgId = setup.orgId;
|
||||
mockFetchLLMCompletion.mockReset().mockResolvedValue({});
|
||||
|
||||
session = {
|
||||
expires: "1",
|
||||
@@ -187,7 +201,7 @@ describe("llmApiKey.all RPC", () => {
|
||||
).rejects.toThrow("User does not have access to this resource or action");
|
||||
});
|
||||
|
||||
it("should require llmApiKeys:update access for testing an existing llm api key", async () => {
|
||||
it("should require llmApiKeys:create access for testing an existing llm api key", async () => {
|
||||
await caller.llmApiKey.create({
|
||||
projectId,
|
||||
provider: "openai",
|
||||
@@ -216,6 +230,116 @@ describe("llmApiKey.all RPC", () => {
|
||||
).rejects.toThrow("User does not have access to this resource or action");
|
||||
});
|
||||
|
||||
it("should block testUpdate when the base URL changes without a new secret key", async () => {
|
||||
await caller.llmApiKey.create({
|
||||
projectId,
|
||||
provider: "openai",
|
||||
adapter: LLMAdapter.OpenAI,
|
||||
secretKey: "sk-original",
|
||||
baseURL: "https://api.openai.com/v1",
|
||||
});
|
||||
|
||||
const existingKey = await prisma.llmApiKeys.findFirstOrThrow({
|
||||
where: {
|
||||
projectId,
|
||||
provider: "openai",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await caller.llmApiKey.testUpdate({
|
||||
id: existingKey.id,
|
||||
projectId,
|
||||
provider: "openai",
|
||||
adapter: LLMAdapter.OpenAI,
|
||||
baseURL: "https://attacker.example.com/v1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: "Secret key is required when changing the base URL",
|
||||
});
|
||||
expect(mockFetchLLMCompletion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should allow testUpdate without a new secret key when the base URL is unchanged", async () => {
|
||||
const existingExtraHeaders = {
|
||||
Authorization: "Bearer stored-token",
|
||||
"X-Custom-Header": "stored-value",
|
||||
};
|
||||
|
||||
await caller.llmApiKey.create({
|
||||
projectId,
|
||||
provider: "openai",
|
||||
adapter: LLMAdapter.OpenAI,
|
||||
secretKey: "sk-original",
|
||||
baseURL: "https://api.openai.com/v1",
|
||||
extraHeaders: existingExtraHeaders,
|
||||
});
|
||||
|
||||
const existingKey = await prisma.llmApiKeys.findFirstOrThrow({
|
||||
where: {
|
||||
projectId,
|
||||
provider: "openai",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await caller.llmApiKey.testUpdate({
|
||||
id: existingKey.id,
|
||||
projectId,
|
||||
provider: "openai",
|
||||
adapter: LLMAdapter.OpenAI,
|
||||
baseURL: "https://api.openai.com/v1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockFetchLLMCompletion).toHaveBeenCalledTimes(1);
|
||||
const llmConnection = mockFetchLLMCompletion.mock.calls[0][0].llmConnection;
|
||||
expect(llmConnection.baseURL).toBe("https://api.openai.com/v1");
|
||||
expect(decrypt(llmConnection.secretKey)).toBe("sk-original");
|
||||
expect(JSON.parse(decrypt(llmConnection.extraHeaders))).toEqual(
|
||||
existingExtraHeaders,
|
||||
);
|
||||
});
|
||||
|
||||
it("should allow testUpdate when the base URL changes and a new secret key is provided", async () => {
|
||||
const existingExtraHeaders = {
|
||||
Authorization: "Bearer stored-token",
|
||||
"X-Custom-Header": "stored-value",
|
||||
};
|
||||
|
||||
await caller.llmApiKey.create({
|
||||
projectId,
|
||||
provider: "openai",
|
||||
adapter: LLMAdapter.OpenAI,
|
||||
secretKey: "sk-original",
|
||||
baseURL: "https://api.openai.com/v1",
|
||||
extraHeaders: existingExtraHeaders,
|
||||
});
|
||||
|
||||
const existingKey = await prisma.llmApiKeys.findFirstOrThrow({
|
||||
where: {
|
||||
projectId,
|
||||
provider: "openai",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await caller.llmApiKey.testUpdate({
|
||||
id: existingKey.id,
|
||||
projectId,
|
||||
provider: "openai",
|
||||
adapter: LLMAdapter.OpenAI,
|
||||
secretKey: "sk-rotated",
|
||||
baseURL: "https://new-endpoint.example.com/v1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockFetchLLMCompletion).toHaveBeenCalledTimes(1);
|
||||
const llmConnection = mockFetchLLMCompletion.mock.calls[0][0].llmConnection;
|
||||
expect(llmConnection.baseURL).toBe("https://new-endpoint.example.com/v1");
|
||||
expect(decrypt(llmConnection.secretKey)).toBe("sk-rotated");
|
||||
expect(llmConnection.extraHeaders).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should create and update an llm api key", async () => {
|
||||
const secret = "test-secret";
|
||||
const provider = "openai";
|
||||
|
||||
+2
-1
@@ -129,6 +129,7 @@ export function ObservationDetailView({
|
||||
setJsonViewPreference,
|
||||
jsonBetaEnabled,
|
||||
setJsonBetaEnabled,
|
||||
isPeekMode,
|
||||
} = useViewPreferences();
|
||||
|
||||
// Map jsonViewPreference to currentView format expected by child components
|
||||
@@ -480,7 +481,7 @@ export function ObservationDetailView({
|
||||
"userId",
|
||||
]}
|
||||
localStorageSuffix="ObservationPreview"
|
||||
disableUrlPersistence
|
||||
disableUrlPersistence={isPeekMode}
|
||||
/>
|
||||
</div>
|
||||
</TabsBarContent>
|
||||
|
||||
@@ -93,6 +93,7 @@ export function TraceDetailView({
|
||||
setJsonViewPreference,
|
||||
jsonBetaEnabled,
|
||||
setJsonBetaEnabled,
|
||||
isPeekMode,
|
||||
} = useViewPreferences();
|
||||
|
||||
// Map jsonViewPreference to currentView format expected by child components
|
||||
@@ -421,7 +422,7 @@ export function TraceDetailView({
|
||||
traceId={trace.id}
|
||||
hiddenColumns={["traceName", "jobConfigurationId", "userId"]}
|
||||
localStorageSuffix="TracePreview"
|
||||
disableUrlPersistence
|
||||
disableUrlPersistence={isPeekMode}
|
||||
/>
|
||||
</div>
|
||||
</TabsBarContent>
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const VERSION = "v3.166.0";
|
||||
export const VERSION = "v3.167.0";
|
||||
|
||||
@@ -17,6 +17,7 @@ import { TraceAnnotationProcessor } from "./processors/TraceAnnotationProcessor"
|
||||
import { SessionAnnotationProcessor } from "./processors/SessionAnnotationProcessor";
|
||||
import { ObjectNotFoundCard } from "@/src/components/ui/object-not-found-card";
|
||||
import { useV4Beta } from "@/src/features/events/hooks/useV4Beta";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
export const AnnotationQueueItemPage: React.FC<{
|
||||
annotationQueueId: string;
|
||||
@@ -25,6 +26,8 @@ export const AnnotationQueueItemPage: React.FC<{
|
||||
queryItemId?: string;
|
||||
}> = ({ annotationQueueId, projectId, view, queryItemId }) => {
|
||||
const router = useRouter();
|
||||
const { status: sessionStatus } = useSession();
|
||||
const sessionLoaded = sessionStatus !== "loading";
|
||||
const { isBetaEnabled } = useV4Beta();
|
||||
const isSingleItem = router.query.singleItem === "true";
|
||||
const [nextItemData, setNextItemData] = useState<
|
||||
@@ -42,7 +45,7 @@ export const AnnotationQueueItemPage: React.FC<{
|
||||
|
||||
const seenItemData = api.annotationQueueItems.byId.useQuery(
|
||||
{ projectId, itemId: itemId as string, isBetaEnabled },
|
||||
{ enabled: !!itemId, refetchOnMount: false },
|
||||
{ enabled: !!itemId && sessionLoaded, refetchOnMount: false },
|
||||
);
|
||||
|
||||
const fetchAndLockNextMutation =
|
||||
@@ -51,18 +54,19 @@ export const AnnotationQueueItemPage: React.FC<{
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
async function fetchNextItem() {
|
||||
if (!itemId && !isSingleItem) {
|
||||
if (!itemId && !isSingleItem && sessionLoaded) {
|
||||
const nextItem = await fetchAndLockNextMutation.mutateAsync({
|
||||
queueId: annotationQueueId,
|
||||
projectId,
|
||||
seenItemIds,
|
||||
isBetaEnabled,
|
||||
});
|
||||
setNextItemData(nextItem);
|
||||
}
|
||||
}
|
||||
fetchNextItem();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [sessionLoaded]);
|
||||
const { configs } = useAnnotationQueueData({ annotationQueueId, projectId });
|
||||
|
||||
const unseenPendingItemCount =
|
||||
@@ -88,6 +92,7 @@ export const AnnotationQueueItemPage: React.FC<{
|
||||
queueId: annotationQueueId,
|
||||
projectId,
|
||||
seenItemIds,
|
||||
isBetaEnabled,
|
||||
});
|
||||
setNextItemData(nextItem);
|
||||
}
|
||||
@@ -144,7 +149,8 @@ export const AnnotationQueueItemPage: React.FC<{
|
||||
(seenItemData.isPending && itemId) ||
|
||||
(fetchAndLockNextMutation.isPending && !itemId) ||
|
||||
unseenPendingItemCount.isPending ||
|
||||
objectData.isLoading
|
||||
objectData.isLoading ||
|
||||
(!sessionLoaded && !isSingleItem)
|
||||
) {
|
||||
return <Skeleton className="h-full w-full" />;
|
||||
}
|
||||
@@ -165,6 +171,7 @@ export const AnnotationQueueItemPage: React.FC<{
|
||||
queueId: annotationQueueId,
|
||||
projectId,
|
||||
seenItemIds,
|
||||
isBetaEnabled,
|
||||
});
|
||||
setNextItemData(nextItem);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
optionalPaginationZod,
|
||||
Prisma,
|
||||
} from "@langfuse/shared";
|
||||
import { getObservationById, logger } from "@langfuse/shared/src/server";
|
||||
import {
|
||||
getObservationById,
|
||||
getObservationByIdFromEventsTable,
|
||||
logger,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -464,78 +468,73 @@ export const queueRouter = createTRPCRouter({
|
||||
queueId: z.string(),
|
||||
projectId: z.string(),
|
||||
seenItemIds: z.array(z.string()),
|
||||
isBetaEnabled: z.boolean().optional().default(false),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
throwIfNoProjectAccess({
|
||||
session: ctx.session,
|
||||
throwIfNoProjectAccess({
|
||||
session: ctx.session,
|
||||
projectId: input.projectId,
|
||||
scope: "annotationQueues:CUD",
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
|
||||
|
||||
const item = await ctx.prisma.annotationQueueItem.findFirst({
|
||||
where: {
|
||||
queueId: input.queueId,
|
||||
projectId: input.projectId,
|
||||
scope: "annotationQueues:CUD",
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
|
||||
|
||||
const item = await ctx.prisma.annotationQueueItem.findFirst({
|
||||
where: {
|
||||
queueId: input.queueId,
|
||||
projectId: input.projectId,
|
||||
status: AnnotationQueueStatus.PENDING,
|
||||
OR: [
|
||||
{ lockedAt: null },
|
||||
{ lockedAt: { lt: fiveMinutesAgo } },
|
||||
{ lockedByUserId: ctx.session.user.id },
|
||||
],
|
||||
NOT: {
|
||||
id: { in: input.seenItemIds },
|
||||
},
|
||||
status: AnnotationQueueStatus.PENDING,
|
||||
OR: [
|
||||
{ lockedAt: null },
|
||||
{ lockedAt: { lt: fiveMinutesAgo } },
|
||||
{ lockedByUserId: ctx.session.user.id },
|
||||
],
|
||||
NOT: {
|
||||
id: { in: input.seenItemIds },
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "asc",
|
||||
},
|
||||
});
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
// Expected behavior, non-error case: all items have been seen AND/OR completed, no more unseen pending items
|
||||
if (!item) return null;
|
||||
// Expected behavior, non-error case: all items have been seen AND/OR completed, no more unseen pending items
|
||||
if (!item) return null;
|
||||
|
||||
const updatedItem = await ctx.prisma.annotationQueueItem.update({
|
||||
where: {
|
||||
id: item.id,
|
||||
projectId: input.projectId,
|
||||
},
|
||||
data: {
|
||||
lockedAt: now,
|
||||
lockedByUserId: ctx.session.user.id,
|
||||
},
|
||||
});
|
||||
const updatedItem = await ctx.prisma.annotationQueueItem.update({
|
||||
where: {
|
||||
id: item.id,
|
||||
projectId: input.projectId,
|
||||
},
|
||||
data: {
|
||||
lockedAt: now,
|
||||
lockedByUserId: ctx.session.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
const inflatedUpdatedItem = {
|
||||
...updatedItem,
|
||||
lockedByUser: { name: ctx.session.user.name },
|
||||
const inflatedUpdatedItem = {
|
||||
...updatedItem,
|
||||
lockedByUser: { name: ctx.session.user.name },
|
||||
};
|
||||
|
||||
if (item.objectType === AnnotationQueueObjectType.OBSERVATION) {
|
||||
const clickhouseObservation = input.isBetaEnabled
|
||||
? await getObservationByIdFromEventsTable({
|
||||
id: item.objectId,
|
||||
projectId: input.projectId,
|
||||
})
|
||||
: await getObservationById({
|
||||
id: item.objectId,
|
||||
projectId: input.projectId,
|
||||
});
|
||||
return {
|
||||
...inflatedUpdatedItem,
|
||||
parentTraceId: clickhouseObservation?.traceId,
|
||||
};
|
||||
|
||||
if (item.objectType === AnnotationQueueObjectType.OBSERVATION) {
|
||||
const clickhouseObservation = await getObservationById({
|
||||
id: item.objectId,
|
||||
projectId: input.projectId,
|
||||
});
|
||||
return {
|
||||
...inflatedUpdatedItem,
|
||||
parentTraceId: clickhouseObservation?.traceId,
|
||||
};
|
||||
}
|
||||
|
||||
return inflatedUpdatedItem;
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
if (error instanceof TRPCError) {
|
||||
throw error;
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Fetching and locking next annotation queue item failed.",
|
||||
});
|
||||
}
|
||||
|
||||
return inflatedUpdatedItem;
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useIsAuthenticatedAndProjectMember } from "@/src/features/auth/hooks";
|
||||
import { parseJsonPrioritised } from "@langfuse/shared";
|
||||
import { ActionButton } from "@/src/components/ActionButton";
|
||||
import { type MetadataDomainClient } from "@/src/utils/clientSideDomainTypes";
|
||||
import { type Prisma } from "@langfuse/shared";
|
||||
|
||||
/**
|
||||
* Component for creating a new dataset item from an existing object.
|
||||
@@ -39,22 +40,30 @@ export const NewDatasetItemFromExistingObject = (props: {
|
||||
traceId?: string;
|
||||
observationId?: string;
|
||||
fromDatasetId?: string;
|
||||
input: string | null;
|
||||
output: string | null;
|
||||
input: Prisma.JsonValue | null;
|
||||
output: Prisma.JsonValue | null;
|
||||
metadata: MetadataDomainClient;
|
||||
isCopyItem?: boolean;
|
||||
buttonVariant?: ButtonProps["variant"];
|
||||
size?: ButtonProps["size"];
|
||||
}) => {
|
||||
const parsedInput =
|
||||
props.input && typeof props.input === "string"
|
||||
? (parseJsonPrioritised(props.input) ?? null)
|
||||
: null;
|
||||
const normalizePrefillValue = (
|
||||
value: Prisma.JsonValue | null,
|
||||
): Prisma.JsonValue | null => {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedOutput =
|
||||
props.output && typeof props.output === "string"
|
||||
? (parseJsonPrioritised(props.output) ?? null)
|
||||
: null;
|
||||
if (typeof value === "string") {
|
||||
const parsed = parseJsonPrioritised(value);
|
||||
return parsed !== undefined ? parsed : value;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const parsedInput = normalizePrefillValue(props.input);
|
||||
const parsedOutput = normalizePrefillValue(props.output);
|
||||
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
const isAuthenticatedAndProjectMember = useIsAuthenticatedAndProjectMember(
|
||||
|
||||
@@ -301,11 +301,29 @@ export default function ExperimentsTable({
|
||||
header: getExperimentsColumnName("experimentDatasetId"),
|
||||
size: 150,
|
||||
cell: ({ row }) => {
|
||||
const key: string | undefined = row.getValue("datasetId");
|
||||
const value = filterOptions.experimentDatasetId?.find(
|
||||
(d) => d.value === key,
|
||||
const datasetId: string | undefined = row.getValue("datasetId");
|
||||
const datasetName = filterOptions.experimentDatasetId?.find(
|
||||
(d) => d.value === datasetId,
|
||||
)?.displayValue;
|
||||
return value ? <TableIdOrName value={value} /> : undefined;
|
||||
|
||||
if (!datasetId || !datasetName) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/project/${projectId}/datasets/${encodeURIComponent(datasetId)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="hover:bg-secondary/80 max-w-full cursor-pointer"
|
||||
>
|
||||
{datasetName}
|
||||
</Badge>
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -442,25 +442,37 @@ export const llmApiKeyRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
const decryptedSecretKey =
|
||||
input.secretKey !== undefined &&
|
||||
input.secretKey !== "" &&
|
||||
input.secretKey !== null
|
||||
? input.secretKey
|
||||
: decrypt(existingKey.secretKey);
|
||||
const hasNewSecretKey =
|
||||
typeof input.secretKey === "string" && input.secretKey.length > 0;
|
||||
const baseURL = input.baseURL ?? existingKey.baseURL;
|
||||
const isBaseURLChanged = baseURL !== existingKey.baseURL;
|
||||
|
||||
if (isBaseURLChanged && !hasNewSecretKey) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Secret key is required when changing the base URL",
|
||||
});
|
||||
}
|
||||
|
||||
const secretKey = hasNewSecretKey
|
||||
? (input.secretKey as string)
|
||||
: decrypt(existingKey.secretKey);
|
||||
|
||||
// Merge existing key with provided input, giving priority to input
|
||||
const secretKey = decryptedSecretKey;
|
||||
const adapter = input.adapter ?? (existingKey.adapter as LLMAdapter);
|
||||
const provider = input.provider ?? existingKey.provider;
|
||||
const baseURL = input.baseURL ?? existingKey.baseURL;
|
||||
const customModels = input.customModels ?? existingKey.customModels;
|
||||
const config = input.config ?? existingKey.config;
|
||||
|
||||
// Never reuse stored headers across a destination change.
|
||||
const extraHeaders =
|
||||
input.extraHeaders ??
|
||||
(existingKey.extraHeaders
|
||||
? decryptAndParseExtraHeaders(existingKey.extraHeaders)
|
||||
: undefined);
|
||||
input.extraHeaders !== undefined
|
||||
? input.extraHeaders
|
||||
: isBaseURLChanged
|
||||
? undefined
|
||||
: existingKey.extraHeaders
|
||||
? decryptAndParseExtraHeaders(existingKey.extraHeaders)
|
||||
: undefined;
|
||||
|
||||
return testLLMConnection({
|
||||
adapter,
|
||||
|
||||
@@ -93,3 +93,9 @@ Use root [AGENTS.md](../AGENTS.md) for monorepo-level rules.
|
||||
- Keep tests independent; no ordering assumptions.
|
||||
- Avoid editing `dist/*` directly.
|
||||
- Coordinate shared changes with `../packages/shared`.
|
||||
- Changes to `src/features/blobstorage/` (export pipeline, enrichment logic,
|
||||
field additions, latency unit handling) should be reviewed against the
|
||||
published blob storage docs for consistency — fetch the latest pages and
|
||||
surface any discrepancies:
|
||||
- https://langfuse.com/docs/api-and-data-platform/features/export-to-blob-storage
|
||||
- https://langfuse.com/docs/api-and-data-platform/features/blob-storage-export-fields
|
||||
|
||||
+10
-10
@@ -14,9 +14,9 @@ RUN corepack enable
|
||||
RUN corepack prepare pnpm@10.33.0 --activate
|
||||
|
||||
FROM --platform=${TARGETPLATFORM:-linux/amd64} alpine AS runtime-base
|
||||
# Remove package managers from the runtime image; only build stages need them.
|
||||
# package managers and build-only CLIs only increase exposure to CVEs -> remove them
|
||||
RUN rm -rf /usr/local/lib/node_modules/corepack /usr/local/lib/node_modules/npm && \
|
||||
rm -f /usr/local/bin/corepack /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/pnpm /usr/local/bin/pnpx /usr/local/bin/yarn /usr/local/bin/yarnpkg
|
||||
rm -f /usr/local/bin/corepack /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/yarn /usr/local/bin/yarnpkg
|
||||
|
||||
FROM --platform=${TARGETPLATFORM:-linux/amd64} build-base AS pruner
|
||||
|
||||
@@ -50,20 +50,19 @@ RUN turbo run build --filter=worker...
|
||||
|
||||
FROM --platform=${TARGETPLATFORM:-linux/amd64} builder AS prod-deps
|
||||
|
||||
RUN rm -rf /prod && \
|
||||
pnpm --filter worker deploy --legacy --prod /prod/worker && \
|
||||
# previously we copied the --from=builder /app . (includes full node_modules etc)
|
||||
# we only need the prod + generated prisma client plus .prisma artifacts
|
||||
# @langfuse/shared still pulls in next-auth transitively, so keep the deploy output
|
||||
# intact here instead of pruning node_modules in Docker.
|
||||
# also, pnpm v10 needs the pnpm legacy deploy implementation, we didn't upgrade that yet
|
||||
RUN pnpm --filter worker deploy --legacy --prod /prod/worker && \
|
||||
builder_prisma_client_dir="$(find /app/node_modules/.pnpm -path '*/node_modules/@prisma/client' -type d | head -n 1)" && \
|
||||
deployed_prisma_client_dir="$(find /prod/worker/node_modules/.pnpm -path '*/node_modules/@prisma/client' -type d | head -n 1)" && \
|
||||
builder_prisma_runtime_dir="$(dirname "$(dirname "$builder_prisma_client_dir")")/.prisma" && \
|
||||
deployed_prisma_runtime_dir="$(dirname "$(dirname "$deployed_prisma_client_dir")")/.prisma" && \
|
||||
rm -rf "$deployed_prisma_client_dir" "$deployed_prisma_runtime_dir" && \
|
||||
mkdir -p "$(dirname "$deployed_prisma_client_dir")" && \
|
||||
cp -R "$builder_prisma_client_dir" "$deployed_prisma_client_dir" && \
|
||||
cp -R "$builder_prisma_runtime_dir" "$deployed_prisma_runtime_dir" && \
|
||||
# @langfuse/shared currently brings auth-only Next.js packages that the worker never executes.
|
||||
find /prod/worker/node_modules \
|
||||
\( -path '*/node_modules/next' -o -path '*/node_modules/next-auth' -o -path '*/.bin/next' \) \
|
||||
-exec rm -rf {} +
|
||||
cp -R "$builder_prisma_runtime_dir" "$deployed_prisma_runtime_dir"
|
||||
|
||||
FROM --platform=${TARGETPLATFORM:-linux/amd64} runtime-base AS runner
|
||||
|
||||
@@ -86,6 +85,7 @@ ARG GID=1001
|
||||
RUN addgroup --system --gid ${GID} expressjs
|
||||
RUN adduser --system --uid ${UID} expressjs
|
||||
|
||||
# Copy only production worker payload instead of full builder workspace (just /prod/worker not entire /app)
|
||||
COPY --from=prod-deps --chown=expressjs:expressjs /prod/worker ./worker
|
||||
RUN chmod +x ./worker/entrypoint.sh
|
||||
USER expressjs
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "worker",
|
||||
"version": "3.166.0",
|
||||
"version": "3.167.0",
|
||||
"description": "",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const VERSION = "v3.166.0";
|
||||
export const VERSION = "v3.167.0";
|
||||
|
||||
Reference in New Issue
Block a user