Compare commits

...
40 Commits
Author SHA1 Message Date
Hassieb Pakzad dd632fea9e chore: release v3.167.0 2026-04-09 14:26:32 +02:00
Hassieb PakzadandGitHub 7527bb0d84 fix(web): require secret key for LLM test base URL changes (#13055) 2026-04-09 14:25:31 +02:00
NimarandGitHub 8cc4a5537f fix(cicd): re-add nextauth etc to docker (#12865)
* fix(cicd): re-add nextauth etc to docker

* clarify

* fix prisma version

* one more comment
2026-04-09 12:09:01 +00:00
marliessophieandGitHub 0a6d3f108a chore(experiments): Link dataset cell in Experiments table to dataset page and show display name (#13059)
fix(experiments): render dataset badge label without table-id component
2026-04-09 12:05:11 +00:00
Ben BachemandGitHub 1bf83313e3 fix(trace-table): Only disable URL persistence for ScoresTable in peek mode (#12963)
fix(web): Only disable URL persistence for `ScoresTable` in peek mode
2026-04-09 11:52:40 +00:00
marliessophieandGitHub 9c3a715d77 fix(annotation): Wait for session to load before rendering annotation queue items (#13058)
fix(web): simplify annotation queue loading state guard
2026-04-09 11:32:58 +00:00
marliessophieandGitHub 0cf2a33473 fix(v4-add-to-dataset): Allow non-string JSON prefill values for new dataset items (#13053)
* fix(datasets): normalize add-to-dataset prefill values

* fix(datasets): preserve parsed null prefill values
2026-04-09 11:11:09 +00:00
Valery MeleshkinandGitHub 51554eb066 chore(dx): add blob storage docs review checks to AGENTS.md (#13056) 2026-04-09 12:24:47 +02:00
marliessophieandGitHub cbc21bb9cc feat(annotation-queues): integrate session handling and beta feature flag in AnnotationQueueItemPage and update router for observation fetching (#13050) 2026-04-09 09:44:50 +00:00
Valery MeleshkinandGitHub 8e30694214 Revert "chore: optional docker setup in Codex setup/maintenance scripts" (#13049)
Revert "chore: optional docker setup in Codex setup/maintenance scripts (#13035)"

This reverts commit 0d20d9de2b.
2026-04-09 10:38:13 +02:00
Valery MeleshkinandGitHub 0d20d9de2b chore: optional docker setup in Codex setup/maintenance scripts (#13035)
* fix(codex): install golang-migrate in docker setup

* fix(codex): include local bin path in maintenance

* fix(codex): load local bin path in setup shell

* fix(codex): verify migrate checksum and safe extract

* fix(codex): improve migrate install error guidance
2026-04-08 17:03:11 +00:00
Tobias Wochinger eeeba25439 chore: release v3.166.0 2026-04-08 19:03:32 +02:00
a59630d656 chore(dx): add more steps for pre-commit (#12901)
* chore(dx): add more stop for pre-commit

* chore: add type checking as well

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: add auto-fixed files to diff

* chore: change to not modifying / remove typecheck

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:04:41 +00:00
Valery MeleshkinandGitHub 10e7dea9c9 fix: getTraceById metadata (#13042) 2026-04-08 17:35:07 +02:00
marliessophieandGitHub 9bf326db4f Revert "fix(dataset-items): update current dataset item versions in upsert function" (#13043)
Revert "fix(dataset-items): update current dataset item versions in upsert fu…"

This reverts commit c393c64a40.
2026-04-08 17:26:31 +02:00
13190c3ec4 perf(trace-ui): Exclude and tool columns from trace observations query when IO is not requested (#12948)
* put prompt_* and tool_* behind 'includeIO'

* ordering

* put prompt outside of io

* also omit for events

---------

Co-authored-by: Nimar <l.nimar.b@gmail.com>
2026-04-08 15:05:50 +00:00
Ben BachemandGitHub 7a4ce9ee5d fix(web): Inconsistent search results between editor and controller (#13038) 2026-04-08 15:05:37 +00:00
NimarandGitHub bc02989ccf fix(ui): remove right screen side handle on mobile (#13036) 2026-04-08 17:02:55 +02:00
NimarandGitHub f0dac0299c chore(deps): bump turbo to 2.9.5 (#13032) 2026-04-08 14:09:50 +00:00
Valery MeleshkinandGitHub 19997064c1 feat(api): add fields parameter to GetTraceById endpoint (#13015) 2026-04-08 13:58:34 +00:00
marliessophieandGitHub c393c64a40 fix(dataset-items): update current dataset item versions in upsert function (#13034) 2026-04-08 13:42:47 +00:00
d04e027107 fix(prompt-automations): prompt creations no longer trigger webhooks for unfiltered event actions (#13000)
* fix(prompt): webhook triggers honor eventAction filters

* fix(automations): added validation of event actions

* test(automations): add deleted event action test case to promptVersionProcessor

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Revert "fix(automations): added validation of event actions"

This reverts commit 210344f49b45fd80e79fecf6841dd37dbe822a28.

* fix(test): update setupTriggerAndAction to match all event actions

The helper used eventActions: ["updated"] which broke the prompt
creation test after eventActions filtering was enforced. Using []
matches all actions, covering both created and updated test cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(ci): retrigger stuck license cla check

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 13:21:03 +00:00
marliessophieandGitHub 53869c7200 fix(dataset-items): handle version conflict errors in dataset item upsert (#13031) 2026-04-08 12:49:20 +00:00
6d964894fb fix(api): return archived item when getting dataset item by ID (#13028)
fix(api): return archived dataset items from GET endpoint

Previously GET /api/public/dataset-items/{id} returned 404 for archived
items. Now it returns them with their status, matching user expectations
for direct ID lookups.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:24:44 +00:00
81653b2bd2 ci: add cla-assistant workflow to retrigger stuck CLA checks (#13021)
Workaround for a known cla-assistant bug where the CLA check gets stuck
after a contributor signs. Comment `/check-cla` on any PR to manually
retrigger. See: https://github.com/cla-assistant/cla-assistant/issues/528

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:02:08 +00:00
marliessophieandGitHub 02d6486f10 chore(dataset-items): patch API on version collision (#13029)
* chore(dataset-items): patch API to throw 4xx instead on version collision

* chore: push
2026-04-08 11:46:38 +00:00
07ee4ed961 fix(web): Improve search highlighting in CodeMirrorEditor (#12961)
* fix(web): Improve search highlighting in CodeMirrorEditor

* Add color variables

* Always call `syncEditorsToQuery` if the active changed

* make selector more spefific

---------

Co-authored-by: Nimar <l.nimar.b@gmail.com>
2026-04-08 11:24:22 +00:00
Hassieb Pakzad 00f770b053 chore: release v3.165.0 2026-04-08 13:25:58 +02:00
Hassieb PakzadandGitHub e12386f9d4 fix(llm-connections): enforce write permissions on LLM connection test endpoints (#13027)
* fix(llm-connections): require llmApiKeys:update permissions for testUpdate endpoint

* fix(llm-connections): preserve forbidden errors in testUpdate
2026-04-08 13:25:13 +02:00
Ben BachemandGitHub b1ac930462 fix(web): Add hint about secrets when copying .env in api key settings (#13001)
* fix(web): Add hint about secrets when copying .env in api key settings

* Integrate claude review feedback

* Address PR feedback
2026-04-08 08:34:07 +00:00
marliessophieandGitHub 8e9521522f fix: Add peek-mode local preview navigation for variable mapping previews (#12945)
* fix(evals): reset preview selection when peek target changes

* chore: push
2026-04-07 20:09:14 +00:00
NimarandGitHub 87ed9fe7e1 fix(data-table): don't auto refetch by default (#13018) 2026-04-07 18:53:07 +00:00
NimarandGitHub 1c40823f0c chore(deps): bump prisma to 6.19.3 for effect (#13017)
* chore(deps): bump prisma to 6.19.3 for effect

* remove override

* remove override
2026-04-07 20:42:35 +02:00
NimarandGitHub 730946f91e chore(deps): bump mcp to 1.29 (#13016) 2026-04-07 18:16:25 +00:00
fbec38c79e ci(sdk): replace poetry with uv in SDK API spec generation workflow (#13012)
* ci(sdk): replace poetry with uv in SDK API spec generation workflow

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* ci: add frozen flag

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:49:08 +00:00
6603be6711 feat(scores): make TEXT scores available via public API (#12937)
* feat(scores):  make `TEXT` scores available via public API

* fix(scores): address review feedback for TEXT score public API

- Remove TEXT example from v1 docs to match CORRECTION pattern
- Split PostScoresBody into v1 (excludes TEXT) and v2 (includes TEXT)
- Fix v2 schema to keep value required for non-TEXT types using
  per-branch extend instead of weakening the foundation schema
- Migrate merge() to extend() across validation schemas

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(scores): address second round of review feedback

- Use local TextData without length constraints in v2 response schema
- Add CreateScoreDataTypeV1 enum in Fern to exclude TEXT from v1 POST
- Use function overloads in convertScoreToPublicApi for proper typing
- Fix misleading comment about v2 POST endpoint

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(scores): support TEXT scores in v1 API

TEXT scores are now fully supported across both v1 and v2 APIs for
create, list, and get-by-id. Only CORRECTION remains v2-only. Uses
LISTABLE_SCORE_TYPES instead of AGGREGATABLE_SCORE_TYPES for v1 filtering.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(scores): include TEXT in CreateScoreValue docs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(scores): update error message and Fern inline docs to mention TEXT scores

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(shared): use literal tuple for LISTABLE_SCORE_TYPES to narrow type

Array.filter() without a type predicate infers ScoreDataTypeType[],
so ListableScoreDataType incorrectly included CORRECTION at the type
level. Define as a literal tuple with `as const` to match the pattern
used by AGGREGATABLE_SCORE_TYPES.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): spread readonly LISTABLE_SCORE_TYPES to satisfy mutable array type

The `as const` readonly tuple was incompatible with the mutable array
parameter expected by `useSidebarFilterState`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(shared): use TEXT_SCORE_MAX_LENGTH global constant in API and ingestion schemas

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): route rollback errors to correct form field for TEXT scores

The rollback error handlers unconditionally set errors on the `.value`
field, but TEXT scores render their `<FormMessage>` on `.stringValue`.
This caused server errors to be silently dropped for TEXT annotations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): fix type errors in rollback error field routing for TEXT scores

Use if/else branches instead of ternary to preserve template literal
types for react-hook-form's setError and clearErrors field paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 14:33:02 +00:00
859c58e96c feat(scores): implement internal changes for TEXT (free form) scores (#12902)
* feat: introduce free form scores

* chore: implement review comments

* chore: rename to `Text` score

* chore: review feedback

* chore: fix exposing scores in traces/observation view, export as well as prompts

* style: polishing for long values

* chore: fix CI issues

* fix(shared,worker): propagate TEXT score data type in export streams and session aggregation

- Extend ClickHouse tuples to include data_type as third element in
  buildScoresAggregationCTE, observation-stream, and trace-stream
- Read actual data_type from tuple instead of hardcoding CATEGORICAL
  in event-stream, observation-stream, and trace-stream
- Include TEXT scores in eventsSessionScoresAggregation filter

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: review

* fix(shared): preserve TEXT data type when inflating ingested scores

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(shared): extract TEXT score max length into global constant

Replace hardcoded max length (500) for TEXT scores with a shared
TEXT_SCORE_MAX_LENGTH constant for reuse across ingestion and public API.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(shared): address PR review comments for TEXT score type

- Rename misleading test names to match actual assertions (TEXT is included)
- Use ListableScoreDataType return type for getScoresGroupedByNameSourceType
- Replace hardcoded maxLength={500} with TEXT_SCORE_MAX_LENGTH constant

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(shared): widen score types to include TEXT in prompt scores and ScoreSimplified

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: last review comment

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:43:14 +00:00
marliessophieandGitHub 316f02f9dc fix(annotation): read data from events table if v4 beta is enabled (#13008)
* fix(annotation): read data from events table if v4 beta is enabled

* fix: read sessions from events table
2026-04-07 12:15:01 +00:00
Valery MeleshkinandGitHub 221d4e4489 feat(otel): warn on oversized request bodies exceeding 16MB (#13007) 2026-04-07 13:47:43 +02:00
Valery MeleshkinandGitHub 4a16f6cf33 fix(worker): clamp Decimal64(12) cost values before ClickHouse insertion (#13005) 2026-04-07 11:32:19 +00:00
115 changed files with 3433 additions and 916 deletions
+3
View File
@@ -235,6 +235,9 @@ LANGFUSE_ENABLE_BLOB_STORAGE_FILE_LOG=true
# Comma-separated default field groups for GET /api/public/traces when no fields param is provided
# Valid values: core, io, scores, observations, metrics
# LANGFUSE_API_TRACES_DEFAULT_FIELDS=
# Comma-separated default field groups for GET /api/public/traces/{traceId} when no fields param is provided
# Valid values: core, io, scores, observations, metrics
# LANGFUSE_API_TRACEBYID_DEFAULT_FIELDS=
### START Enterprise Edition Configuration
+20
View File
@@ -0,0 +1,20 @@
# Workaround for a known cla-assistant bug where the CLA check gets stuck and
# never re-runs after a contributor signs the CLA.
# See: https://github.com/cla-assistant/cla-assistant/issues/528
#
# Usage: comment `/check-cla` on any PR to manually retrigger the CLA check.
name: CLA Assistant
on:
issue_comment:
types: [created]
jobs:
retrigger_cla:
# Only run on PR comments (not issue comments) with the /check-cla command
if: github.event.issue.pull_request && github.event.comment.body == '/check-cla'
runs-on: ubuntu-latest
steps:
- name: Retrigger CLA check
run: |
curl -s "https://cla-assistant.io/check/langfuse/langfuse?pullRequest=${{ github.event.issue.number }}"
+9 -5
View File
@@ -5,7 +5,7 @@ on:
branches:
- main
paths:
- 'fern/**'
- "fern/**"
workflow_dispatch:
concurrency:
@@ -38,6 +38,11 @@ jobs:
FERN_TOKEN: ${{ secrets.FERN_TOKEN }}
run: npx fern-api generate --api server --force
- name: Install uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8
with:
version: "0.11.2"
- name: Update Python SDK
env:
GH_ACCESS_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }}
@@ -61,10 +66,9 @@ jobs:
# (could not find the right option to prevent this in generation step)
rm -rf langfuse/api/tests
# Install poetry and format
pip install poetry
poetry install --all-extras
poetry run ruff format langfuse/api
# Install dependencies and format
uv sync --all-extras --frozen
uv run ruff format langfuse/api
# Check for changes and show diff for debugging
if git diff --quiet; then
+4 -2
View File
@@ -11,13 +11,15 @@ if [ "$current_branch" = "$protected_branch" ]; then
echo "🚨 You are about to commit to the $protected_branch branch. Are you sure? (y/n)"
read -r answer < /dev/tty
if [ "$answer" != "${answer#[Yy]}" ]; then
# Commit approved, check formatting. On files changed, block commit
# Commit approved, run checks
pnpm run format:check
pnpm run lint
else
echo "Commit to $protected_branch branch has been canceled."
exit 1 # Commit will be blocked
fi
fi
# If not the protected branch, check formatting (on changed files, block)
# If not the protected branch, run checks
pnpm run format:check
pnpm run lint
+44 -39
View File
@@ -418,45 +418,48 @@ The background color of the following component will be `hsl(var(--primary))` an
### Color Variables
| Variable | Description | Examples |
| ------------------------ | ------------------------------------------------------------------ | -------------------------------- |
| --background | Background color | Default background color of body |
| --foreground | Foreground color | Default text color of body |
| --muted | Muted background color | TabsList, Skeleton and Switch |
| --muted-foreground | Muted foreground color | |
| --popover | Popover background color | DropdownMenu, HoverCard, Popover |
| --popover-foreground | Popover foreground color | |
| --card | Card background color | Card |
| --card-foreground | Card foreground color | |
| --border | Border color | Default border color |
| --input | Input field border color | Input, Select, Textarea |
| --primary | Primary button background colors | Button variant="primary" |
| --primary-foreground | Primary button foreground color | |
| --secondary | Secondary button background color | Button variant="secondary" |
| --secondary-foreground | Secondary button foreground color | |
| --accent | Used for accents such as hover effects | DropdownMenuItem, SelectItem |
| --accent-foreground | Used for texts on hover effects | DropdownMenuItem, SelectItem |
| --destructive | Destructive action color for background | Button variant="destructive" |
| --destructive-foreground | Destructive action color for text | |
| --ring | Focus ring color | MultiSelect |
| --primary-accent | Primary accent color used for branding | Layout |
| --hover-primary-accent | Primary accent color used for hover effects for links | SignIn and AuthCloudRegionSwitch |
| --muted-green | Muted green for Event label | ObservationTree |
| --muted-magenta | Muted magenta for Generation label | ObservationTree |
| --muted-blue | Muted blue for Span label | ObservationTree |
| --muted-gray | Muted gray for disabled status badges | StatusBadge |
| --accent-light-green | Light green accent for background of output and assistant messages | IOPreview, Generations, Traces |
| --accent-dark-green | Dark green accent for border of output and assistant messages | CodeJsonViewer and IOPReview |
| --light-red | Light red for error background | level-color and StatusBadge |
| --dark-red | Dark red for error text and error badge dot color | level-color and ErrorPage |
| --light-yellow | Light yellow for warning background | LevelColor |
| --dark-yellow | Dark yellow for warning text | LevelColor |
| --light-green | Light green for success status badge background | StatusBadge |
| --dark-green | Dark green for success status badge text and dot | StatusBadge |
| --light-blue | Light blue for background of Staging label | LangfuseLogo |
| --dark-blue | Dark blue for text and border of Staging label | LangfuseLogo |
| --accent-light-blue | Light blue accent for table link hover effect | TableLink |
| --accent-dark-blue | Dark blue accent for table link text | TableLink |
| Variable | Description | Examples |
| -------------------------------- | ------------------------------------------------------------------ | -------------------------------- |
| --background | Background color | Default background color of body |
| --foreground | Foreground color | Default text color of body |
| --muted | Muted background color | TabsList, Skeleton and Switch |
| --muted-foreground | Muted foreground color | |
| --popover | Popover background color | DropdownMenu, HoverCard, Popover |
| --popover-foreground | Popover foreground color | |
| --card | Card background color | Card |
| --card-foreground | Card foreground color | |
| --border | Border color | Default border color |
| --input | Input field border color | Input, Select, Textarea |
| --primary | Primary button background colors | Button variant="primary" |
| --primary-foreground | Primary button foreground color | |
| --secondary | Secondary button background color | Button variant="secondary" |
| --secondary-foreground | Secondary button foreground color | |
| --accent | Used for accents such as hover effects | DropdownMenuItem, SelectItem |
| --accent-foreground | Used for texts on hover effects | DropdownMenuItem, SelectItem |
| --destructive | Destructive action color for background | Button variant="destructive" |
| --destructive-foreground | Destructive action color for text | |
| --ring | Focus ring color | MultiSelect |
| --primary-accent | Primary accent color used for branding | Layout |
| --hover-primary-accent | Primary accent color used for hover effects for links | SignIn and AuthCloudRegionSwitch |
| --muted-green | Muted green for Event label | ObservationTree |
| --muted-magenta | Muted magenta for Generation label | ObservationTree |
| --muted-blue | Muted blue for Span label | ObservationTree |
| --muted-gray | Muted gray for disabled status badges | StatusBadge |
| --accent-light-green | Light green accent for background of output and assistant messages | IOPreview, Generations, Traces |
| --accent-dark-green | Dark green accent for border of output and assistant messages | CodeJsonViewer and IOPReview |
| --light-red | Light red for error background | level-color and StatusBadge |
| --dark-red | Dark red for error text and error badge dot color | level-color and ErrorPage |
| --light-yellow | Light yellow for warning background | LevelColor |
| --dark-yellow | Dark yellow for warning text | LevelColor |
| --light-green | Light green for success status badge background | StatusBadge |
| --dark-green | Dark green for success status badge text and dot | StatusBadge |
| --light-blue | Light blue for background of Staging label | LangfuseLogo |
| --dark-blue | Dark blue for text and border of Staging label | LangfuseLogo |
| --accent-light-blue | Light blue accent for table link hover effect | TableLink |
| --accent-dark-blue | Dark blue accent for table link text | TableLink |
| --find-match-selected-background | Background color for selected search matches | CodeMirrorEditor |
| --find-match-selected-foreground | Foreground color for selected search matches | CodeMirrorEditor |
| --find-match-background | Background color for search matches | CodeMirrorEditor |
### Adding New Colors
@@ -510,3 +513,5 @@ npx fern-api generate --api organizations # for the organizations API
Langfuse is MIT licensed, except for `ee/` folder. See [LICENSE](LICENSE) and [docs](https://langfuse.com/docs/open-source) for more details.
When contributing to the Langfuse codebase, you need to agree to the [Contributor License Agreement](https://cla-assistant.io/langfuse/langfuse). You only need to do this once and the CLA bot will remind you if you haven't signed it yet.
If the CLA check gets stuck after signing (a [known cla-assistant bug](https://github.com/cla-assistant/cla-assistant/issues/520)), comment `/check-cla` on your PR to retrigger it.
+21 -1
View File
@@ -431,6 +431,12 @@ types:
stringValue:
type: string
docs: The string representation of the score value. If no config is linked, can be any string. Otherwise, must map to a config category
TextScoreV1:
extends: BaseScoreV1
properties:
stringValue:
type: string
docs: The text content of the score (1-500 characters)
ScoreV1:
discriminant: "dataType"
union:
@@ -443,6 +449,9 @@ types:
BOOLEAN:
type: BooleanScoreV1
docs: "Score with BOOLEAN data type"
TEXT:
type: TextScoreV1
docs: "Score with TEXT data type"
# Source: packages/shared/src/features/scores/interfaces/api/v2/schemas.ts - APIScoreSchemaV2
BaseScore:
@@ -516,6 +525,12 @@ types:
stringValue:
type: string
docs: The string representation of the correction content
TextScore:
extends: BaseScore
properties:
stringValue:
type: string
docs: The text content of the score (1-500 characters)
Score:
discriminant: "dataType"
union:
@@ -531,13 +546,16 @@ types:
CORRECTION:
type: CorrectionScore
docs: "Score with CORRECTION data type"
TEXT:
type: TextScore
docs: "Score with TEXT data type"
CreateScoreValue:
discriminated: false
union:
- double
- string
docs: The value of the score. Must be passed as string for categorical scores, and numeric for boolean and numeric scores
docs: The value of the score. Must be passed as string for categorical and text scores, and numeric for boolean and numeric scores
# Source: web/src/features/public-api/types/comments.ts - APIComment
Comment:
@@ -932,6 +950,7 @@ types:
- NUMERIC
- BOOLEAN
- CATEGORICAL
- TEXT
ScoreDataType:
enum:
@@ -939,6 +958,7 @@ types:
- BOOLEAN
- CATEGORICAL
- CORRECTION
- TEXT
errors:
Error:
+1 -1
View File
@@ -287,7 +287,7 @@ types:
docs: The annotation queue referenced by the score. Indicates if score was initially created while processing annotation queue.
value:
type: commons.CreateScoreValue
docs: The value of the score. Must be passed as string for categorical scores, and numeric for boolean and numeric scores. Boolean score values must equal either 1 or 0 (true or false)
docs: The value of the score. Must be passed as string for categorical and text scores, and numeric for boolean and numeric scores. Boolean score values must equal either 1 or 0 (true or false). Text score values must be between 1 and 500 characters.
comment: optional<string>
metadata: optional<unknown>
dataType:
@@ -31,7 +31,7 @@ types:
name: string
value:
type: commons.CreateScoreValue
docs: The value of the score. Must be passed as string for categorical scores, and numeric for boolean and numeric scores. Boolean score values must equal either 1 or 0 (true or false)
docs: The value of the score. Must be passed as string for categorical and text scores, and numeric for boolean and numeric scores. Boolean score values must equal either 1 or 0 (true or false). Text score values must be between 1 and 500 characters.
comment: optional<string>
metadata: optional<map<string, unknown>>
environment:
@@ -85,6 +85,11 @@ types:
dataType: "BOOLEAN"
configId: "1234-5678-90ab-cdef"
traceId: "cdef-1234-5678-90ab"
- value:
name: "feedback"
value: "Great explanation of the concept"
dataType: "TEXT"
traceId: "cdef-1234-5678-90ab"
CreateScoreResponse:
properties:
id:
+6
View File
@@ -130,6 +130,11 @@ types:
properties:
trace: optional<GetScoresResponseTraceData>
GetScoresResponseDataText:
extends: commons.TextScore
properties:
trace: optional<GetScoresResponseTraceData>
GetScoresResponseData:
discriminant: dataType
union:
@@ -137,6 +142,7 @@ types:
CATEGORICAL: GetScoresResponseDataCategorical
BOOLEAN: GetScoresResponseDataBoolean
CORRECTION: GetScoresResponseDataCorrection
TEXT: GetScoresResponseDataText
GetScoresResponse:
properties:
+6
View File
@@ -10,6 +10,12 @@ service:
docs: Get a specific trace
method: GET
path: /traces/{traceId}
request:
name: GetTraceRequest
query-parameters:
fields:
type: optional<string>
docs: "Comma-separated list of fields to include in the response. Available field groups: 'core' (always included), 'io' (input, output, metadata), 'scores', 'observations', 'metrics'. If not specified, all fields are returned. Example: 'core,scores,metrics'. Note: Excluded 'observations' or 'scores' fields return empty arrays; excluded 'metrics' returns -1 for 'totalCost' and 'latency'."
path-parameters:
traceId:
type: string
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "3.164.0",
"version": "3.167.0",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
@@ -48,7 +48,7 @@
"husky": "^9.1.7",
"prettier": "^3.8.1",
"release-it": "^19.2.4",
"turbo": "2.8.20"
"turbo": "2.9.5"
},
"release-it": {
"git": {
@@ -104,7 +104,6 @@
"nanoid": "^3.3.8",
"katex": "^0.16.21",
"tar-fs": "^2.1.2",
"effect": "^3.20.0",
"rollup@^4.0.0": "^4.22.4",
"@types/node-fetch": "^2.6.13",
"@types/react-dom": "19.2.3",
+1 -1
View File
@@ -18,7 +18,7 @@
"@eslint/js": "^9.39.2",
"eslint-config-next": "16.2.1",
"eslint-config-prettier": "^10.1.8",
"eslint-config-turbo": "2.8.20",
"eslint-config-turbo": "2.9.5",
"eslint-plugin-only-warn": "^1.1.0",
"eslint-plugin-prettier": "^5.5.4",
"globals": "^16.0.0",
+14
View File
@@ -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
+2 -2
View File
@@ -91,7 +91,7 @@
"@langchain/google-vertexai": "^2.1.13",
"@langchain/openai": "^1.2.3",
"@opentelemetry/api": ">=1.0.0 <1.10.0",
"@prisma/client": "^6.17.1",
"@prisma/client": "^6.19.3",
"@react-email/components": "^0.5.1",
"@react-email/render": "^1.2.1",
"@slack/oauth": "^3.0.4",
@@ -131,7 +131,7 @@
"@types/uuid": "^9.0.8",
"eslint": "^9.39.2",
"prettier": "^3.8.1",
"prisma": "^6.17.1",
"prisma": "^6.19.3",
"ts-node": "^10.9.2",
"tsc-watch": "^6.2.0",
"typescript": "^5.7.2"
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "ScoreConfigDataType" ADD VALUE 'TEXT';
+2 -1
View File
@@ -495,6 +495,7 @@ enum ScoreConfigDataType {
CATEGORICAL
NUMERIC
BOOLEAN
TEXT
}
model AnnotationQueue {
@@ -924,7 +925,7 @@ model EvalTemplate {
provider String?
modelParams Json? @map("model_params")
vars String[] @default([])
outputDefinition Json @map("output_schema")
outputDefinition Json @map("output_schema")
JobConfiguration JobConfiguration[]
JobExecution JobExecution[]
@@ -965,6 +965,14 @@ async function generateConfigs(project: Project) {
"Used to indicate if text was harmful or offensive in nature.",
isArchived: false,
},
{
id: `config-${v4()}`,
projectId: project.id,
name: "Feedback",
dataType: ScoreDataTypeEnum.TEXT,
description: "Free-form text feedback on the output quality.",
isArchived: false,
},
];
for (const config of configs) {
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.164.0";
export const VERSION = "v3.167.0";
@@ -72,6 +72,13 @@ export const CategoricalConfigFields = z.object({
categories: z.array(ScoreConfigCategory).superRefine(validateCategories),
});
export const TextConfigFields = z.object({
maxValue: z.undefined().nullish(),
minValue: z.undefined().nullish(),
dataType: z.literal("TEXT"),
categories: z.undefined().nullish(),
});
const ScoreConfigBase = z.object({
id: z.string(),
name: z.string().min(1).max(35),
@@ -114,6 +121,10 @@ export const ScoreConfigSchema = z
...ScoreConfigBase.shape,
...BooleanConfigFields.shape,
}),
z.object({
...ScoreConfigBase.shape,
...TextConfigFields.shape,
}),
])
.superRefine(validateNumericRangeFields);
+20
View File
@@ -12,17 +12,21 @@ export type ScoreSourceType = z.infer<typeof ScoreSourceDomain>;
export const CORRECTION_NAME = "output" as const;
export const TEXT_SCORE_MAX_LENGTH = 500 as const;
export const ScoreDataTypeArray = [
"NUMERIC",
"CATEGORICAL",
"BOOLEAN",
"CORRECTION",
"TEXT",
] as const;
export const ScoreDataTypeEnum = {
NUMERIC: "NUMERIC",
CATEGORICAL: "CATEGORICAL",
BOOLEAN: "BOOLEAN",
CORRECTION: "CORRECTION",
TEXT: "TEXT",
} as const;
export const ScoreDataTypeDomain = z.enum(ScoreDataTypeArray);
export type ScoreDataTypeType = z.infer<typeof ScoreDataTypeDomain>;
@@ -47,6 +51,11 @@ const CorrectionData = z.object({
dataType: z.literal("CORRECTION"),
});
export const TextData = z.object({
stringValue: z.string().min(1).max(TEXT_SCORE_MAX_LENGTH),
dataType: z.literal("TEXT"),
});
// Only used for backwards compatibility with old score API schemas
export const ScoreSchemaExclReferencesAndDates = z.object({
// Core identifiers
@@ -89,6 +98,7 @@ export const ScoreSchema = ScoreFoundationSchema.and(
CategoricalData,
BooleanData,
CorrectionData,
TextData,
]),
);
@@ -119,3 +129,13 @@ export type AggregatableScoreDataType =
export type AggregatableScore = ScoresByDataTypes<
typeof AGGREGATABLE_SCORE_TYPES
>;
export const LISTABLE_SCORE_TYPES = [
"NUMERIC",
"BOOLEAN",
"CATEGORICAL",
"TEXT",
] as const satisfies readonly ScoreDataTypeType[];
export type ListableScoreDataType = (typeof LISTABLE_SCORE_TYPES)[number];
export type ListableScore = ScoresByDataTypes<typeof LISTABLE_SCORE_TYPES>;
@@ -1,6 +1,11 @@
import z from "zod";
import { StringNoHTML, StringNoHTMLNonEmpty } from "../../utils/zod";
import { BooleanData, CategoricalData, NumericData } from "../../domain";
import {
BooleanData,
CategoricalData,
TextData,
NumericData,
} from "../../domain";
const ScoreTargetTrace = z.object({
type: z.literal("trace"),
@@ -45,7 +50,12 @@ const UpdateAnnotationScoreBase = CreateAnnotationScoreBase.extend({
* For langfuse score types please refer to `web/src/features/public-api/types/scores.ts`
*/
export const CreateAnnotationScoreData = CreateAnnotationScoreBase.and(
z.discriminatedUnion("dataType", [NumericData, CategoricalData, BooleanData]),
z.discriminatedUnion("dataType", [
NumericData,
CategoricalData,
BooleanData,
TextData,
]),
);
export type CreateAnnotationScoreData = z.infer<
@@ -57,7 +67,12 @@ export type CreateAnnotationScoreData = z.infer<
* For langfuse score types please refer to `web/src/features/public-api/types/scores.ts`
*/
export const UpdateAnnotationScoreData = UpdateAnnotationScoreBase.and(
z.discriminatedUnion("dataType", [NumericData, CategoricalData, BooleanData]),
z.discriminatedUnion("dataType", [
NumericData,
CategoricalData,
BooleanData,
TextData,
]),
);
export type UpdateAnnotationScoreData = z.infer<
@@ -6,6 +6,7 @@ import { PostScoreBodyFoundationSchema } from "../shared";
import {
ScoreDataTypeDomain,
ScoreSourceDomain,
TEXT_SCORE_MAX_LENGTH,
} from "../../../../domain/scores";
import { singleFilter } from "../../../../interfaces/filters";
import { InvalidRequestError } from "../../../../errors";
@@ -74,7 +75,6 @@ export const GetScoresQuery = z.object({
});
// POST /scores
// Please note that the POST /scores endpoint supports all score types (trace, session, dataset run) across v1 and v2.
/**
* PostScoresBody is copied for the ingestion API as `ScoreBody`. Please copy any changes here in `packages/shared/src/features/ingestion/types.ts`
*/
@@ -104,6 +104,11 @@ export const PostScoresBody = applyScoreValidation(
dataType: z.literal("CORRECTION"),
configId: z.undefined().nullish(), // Cannot have config
}),
z.object({
value: z.string().min(1).max(TEXT_SCORE_MAX_LENGTH),
dataType: z.literal("TEXT"),
configId: z.string().nullish(),
}),
z.object({
value: z.union([z.string(), z.number()]),
dataType: z.undefined(),
@@ -9,7 +9,7 @@ import {
/**
* Foundation schema for scores API v1 i.e. trace and observation scores ONLY
*
* Must be extended with score data specific schema (numeric, categorical, boolean)
* Must be extended with score data specific schema (numeric, categorical, boolean, text)
* @see {@link NumericData}, {@link CategoricalData}, {@link BooleanData}
*/
const ScoreFoundationSchemaV1 = ScoreSchemaExclReferencesAndDates.extend({
@@ -22,8 +22,17 @@ const ScoreFoundationSchemaV1 = ScoreSchemaExclReferencesAndDates.extend({
observationId: z.string().nullish(),
});
export const APIScoreSchemaV1 = ScoreFoundationSchemaV1.and(
z.discriminatedUnion("dataType", [NumericData, CategoricalData, BooleanData]),
);
// Response-only schema without input validation constraints (e.g. length limits)
const TextData = z.object({
stringValue: z.string(),
dataType: z.literal("TEXT"),
});
export const APIScoreSchemaV1 = z.discriminatedUnion("dataType", [
ScoreFoundationSchemaV1.extend(NumericData.shape),
ScoreFoundationSchemaV1.extend(CategoricalData.shape),
ScoreFoundationSchemaV1.extend(BooleanData.shape),
ScoreFoundationSchemaV1.omit({ value: true }).extend(TextData.shape),
]);
export type APIScoreV1 = z.infer<typeof APIScoreSchemaV1>;
@@ -6,11 +6,18 @@ import {
} from "../../../../../domain";
import z from "zod";
// Response-only schemas without input validation constraints (e.g. length limits).
// Input constraints are enforced at write time; response schemas must accept any stored value.
const CorrectionData = z.object({
stringValue: z.string(),
dataType: z.literal("CORRECTION"),
});
const TextData = z.object({
stringValue: z.string(),
dataType: z.literal("TEXT"),
});
/**
* Foundation schema for scores API v2 i.e. trace, observation AND session scores
*
@@ -29,13 +36,13 @@ const ScoreFoundationSchemaV2 = ScoreSchemaExclReferencesAndDates.extend({
datasetRunId: z.string().nullish(),
});
export const APIScoreSchemaV2 = ScoreFoundationSchemaV2.and(
z.discriminatedUnion("dataType", [
NumericData,
CategoricalData,
BooleanData,
CorrectionData,
]),
);
export const APIScoreSchemaV2 = z.discriminatedUnion("dataType", [
ScoreFoundationSchemaV2.extend(NumericData.shape),
ScoreFoundationSchemaV2.extend(CategoricalData.shape),
ScoreFoundationSchemaV2.extend(BooleanData.shape),
ScoreFoundationSchemaV2.extend(CorrectionData.shape),
// a numeric value does not make sense for TEXT scores, so we omit the property
ScoreFoundationSchemaV2.omit({ value: true }).extend(TextData.shape),
]);
export type APIScoreV2 = z.infer<typeof APIScoreSchemaV2>;
@@ -3,34 +3,41 @@ import { applyScoreValidation } from "../../../../utils/scores";
import { PostScoreBodyFoundationSchema } from "../shared";
import { isPresent } from "../../../../utils/typeChecks";
import { ScoreConfigCategory } from "../../../../domain/score-configs";
import { TEXT_SCORE_MAX_LENGTH } from "../../../../domain/scores";
export const ScoreBodyWithoutConfig = applyScoreValidation(
z.discriminatedUnion("dataType", [
PostScoreBodyFoundationSchema.merge(
PostScoreBodyFoundationSchema.extend(
z.object({
value: z.number(),
dataType: z.literal("NUMERIC"),
}),
}).shape,
),
PostScoreBodyFoundationSchema.merge(
PostScoreBodyFoundationSchema.extend(
z.object({
value: z.string(),
dataType: z.literal("CATEGORICAL"),
}),
}).shape,
),
PostScoreBodyFoundationSchema.merge(
PostScoreBodyFoundationSchema.extend(
z.object({
value: z.string(),
dataType: z.literal("CORRECTION"),
}),
}).shape,
),
PostScoreBodyFoundationSchema.merge(
PostScoreBodyFoundationSchema.extend(
z.object({
value: z.number().refine((val) => val === 0 || val === 1, {
message: "Value must be either 0 or 1",
}),
dataType: z.literal("BOOLEAN"),
}),
}).shape,
),
PostScoreBodyFoundationSchema.extend(
z.object({
value: z.string().min(1).max(TEXT_SCORE_MAX_LENGTH),
dataType: z.literal("TEXT"),
}).shape,
),
]),
);
@@ -81,4 +88,8 @@ export const ScorePropsAgainstConfig = z.union([
}),
dataType: z.literal("BOOLEAN"),
}),
z.object({
value: z.string().min(1).max(TEXT_SCORE_MAX_LENGTH),
dataType: z.literal("TEXT"),
}),
]);
@@ -1,5 +1,5 @@
import {
AggregatableScoreDataType,
ListableScoreDataType,
MetadataDomain,
ScoreSourceType,
} from "../../../../domain";
@@ -30,7 +30,7 @@ export type ScoreAggregate = Record<string, AggregatedScoreData>;
export type ScoreSimplified = {
id: string;
name: string;
dataType: AggregatableScoreDataType;
dataType: ListableScoreDataType;
source: ScoreSourceType;
value?: number | null;
comment?: string | null;
+18 -10
View File
@@ -4,6 +4,7 @@ import { z } from "zod";
import { NonEmptyString, jsonSchema } from "../../utils/zod";
import { ModelUsageUnit } from "../../constants";
import { ScoreSourceType } from "../../domain";
import { TEXT_SCORE_MAX_LENGTH } from "../../domain/scores";
import { applyScoreValidation } from "../../utils/scores";
export const idSchema = z
@@ -526,21 +527,21 @@ const createAllIngestionSchemas = ({
const ScoreBody = applyScoreValidation(
z.discriminatedUnion("dataType", [
BaseScoreBody.merge(
BaseScoreBody.extend(
z.object({
value: z.number(),
dataType: z.literal("NUMERIC"),
configId: z.string().nullish(),
}),
}).shape,
),
BaseScoreBody.merge(
BaseScoreBody.extend(
z.object({
value: z.string(),
dataType: z.literal("CATEGORICAL"),
configId: z.string().nullish(),
}),
}).shape,
),
BaseScoreBody.merge(
BaseScoreBody.extend(
z.object({
value: z.number().refine((value) => value === 0 || value === 1, {
message:
@@ -548,21 +549,28 @@ const createAllIngestionSchemas = ({
}),
dataType: z.literal("BOOLEAN"),
configId: z.string().nullish(),
}),
}).shape,
),
BaseScoreBody.merge(
BaseScoreBody.extend(
z.object({
value: z.string(),
dataType: z.literal("CORRECTION"),
configId: z.undefined().nullish(), // Cannot have config
}),
}).shape,
),
BaseScoreBody.merge(
BaseScoreBody.extend(
z.object({
value: z.string().min(1).max(TEXT_SCORE_MAX_LENGTH),
dataType: z.literal("TEXT"),
configId: z.string().nullish(),
}).shape,
),
BaseScoreBody.extend(
z.object({
value: z.union([z.string(), z.number()]),
dataType: z.undefined(),
configId: z.string().nullish(),
}),
}).shape,
),
]),
);
@@ -64,7 +64,7 @@ export async function validateAndInflateScore(
if (!validation.success) {
throw new InvalidRequestError(
`Ingested score value type not valid against provided data type. Provide numeric values for numeric and boolean scores, and string values for categorical scores.`,
`Ingested score value type not valid against provided data type. Provide numeric values for numeric and boolean scores, string values for categorical scores, and string values of 1-500 characters for text scores.`,
);
}
@@ -141,6 +141,15 @@ function inflateScoreBody(
};
}
if (relevantDataType === ScoreDataTypeEnum.TEXT) {
return {
...scoreProps,
value: 0,
stringValue: body.value,
dataType: ScoreDataTypeEnum.TEXT,
};
}
return {
...scoreProps,
value: config ? mapStringValueToNumericValue(config, body.value) : 0,
@@ -173,6 +182,7 @@ function resolveScoreValueAnnotation(
case ScoreDataTypeEnum.BOOLEAN:
return body.value;
case ScoreDataTypeEnum.CATEGORICAL:
case ScoreDataTypeEnum.TEXT:
return body.stringValue;
case ScoreDataTypeEnum.CORRECTION:
throw new Error("CORRECTION type not supported in annotation drawer");
@@ -185,6 +185,39 @@ const FIELD_SETS = {
"toolCalls",
"toolCallNames",
],
baseWithoutTools: [
"id",
"type",
"projectId",
"name",
"modelParameters",
"startTime",
"endTime",
"traceId",
"completionStartTime",
"providedUsageDetails",
"usageDetails",
"providedCostDetails",
"costDetails",
"level",
"environment",
"bookmarked",
"public",
"statusMessage",
"version",
"parentObservationId",
"createdAt",
"updatedAt",
"providedModelName",
"totalCost",
"promptId",
"promptName",
"promptVersion",
"internalModelId",
"userId",
"sessionId",
"traceName",
],
calculated: ["latency", "timeToFirstToken"],
io: ["input", "output"],
metadata: ["metadata"],
@@ -74,7 +74,7 @@ interface BaseScoresAggregationParams extends BaseScoresParams {
hasScoreAggregationFilters?: boolean;
/**
* When true, adds an extra `score_categories_tuples` column with
* `tuple(name, string_value)` encoding alongside the default concat-encoded
* `tuple(name, string_value, data_type)` encoding alongside the default concat-encoded
* `score_categories`. The tuple column is safe for programmatic parsing
* (e.g. batch exports) when score names may contain colons.
* The concat column is always present for hasAny filter compatibility.
@@ -120,7 +120,7 @@ export const buildScoresAggregationCTE = (
${primaryKey},
${additionalOuterCols.length > 0 ? additionalOuterCols.join(",\n ") + "," : ""}
groupArrayIf(tuple(name, avg_value, data_type, string_value), data_type IN ('NUMERIC', 'BOOLEAN')) AS scores_avg,
groupArrayIf(concat(name, ':', string_value), data_type = 'CATEGORICAL' AND notEmpty(string_value)) AS score_categories${params.includeTupleEncoding ? `,\n groupArrayIf(tuple(name, string_value), data_type = 'CATEGORICAL' AND notEmpty(string_value)) AS score_categories_tuples` : ""}
groupArrayIf(concat(name, ':', string_value), data_type IN ('CATEGORICAL', 'TEXT') AND notEmpty(string_value)) AS score_categories${params.includeTupleEncoding ? `,\n groupArrayIf(tuple(name, string_value, data_type), data_type IN ('CATEGORICAL', 'TEXT') AND notEmpty(string_value)) AS score_categories_tuples` : ""}
FROM (
SELECT
${primaryKey},
@@ -319,7 +319,7 @@ export const eventsSessionScoresAggregation = (params: {
) AS scores_avg,
groupArrayIf(
concat(name, ':', string_value),
data_type = 'CATEGORICAL' AND notEmpty(string_value)
data_type IN ('CATEGORICAL', 'TEXT') AND notEmpty(string_value)
) AS score_categories
FROM (
SELECT
@@ -375,16 +375,35 @@ export async function upsertDatasetItem(
[Implementation.VERSIONED]: async () => {
// VERSIONED: Invalidate old row by setting valid_to, then create new row
await prisma.$transaction(async (tx) => {
const newValidFrom = new Date();
// 0. Re-read if there is an existing item to get the validFrom timestamp
const current = await tx.datasetItem.findFirst({
where: {
id: itemId,
projectId: props.projectId,
validTo: null,
},
orderBy: {
validFrom: "desc",
},
});
if (current && current.datasetId !== dataset.id) {
throw new LangfuseNotFoundError(
`Dataset item with id ${itemId} not found for project ${props.projectId}`,
);
}
const baseTs = current?.validFrom.getTime() ?? 0;
const newValidFrom = new Date(Math.max(Date.now(), baseTs + 1));
// 1. If updating existing item, invalidate the current version
if (existingItem) {
if (current) {
await tx.datasetItem.update({
where: {
id_projectId_validFrom: {
id: existingItem.id,
id: current.id,
projectId: props.projectId,
validFrom: existingItem.validFrom,
validFrom: current.validFrom,
},
},
data: {
@@ -1,4 +1,4 @@
import { AGGREGATABLE_SCORE_TYPES } from "../../domain/scores";
import { LISTABLE_SCORE_TYPES } from "../../domain/scores";
import { queryClickhouse } from "./clickhouse";
export type EnvironmentFilterProps = {
@@ -38,7 +38,7 @@ export const getEnvironmentsForProject = async (
params: {
projectId,
fromTimestamp,
dataTypes: AGGREGATABLE_SCORE_TYPES,
dataTypes: LISTABLE_SCORE_TYPES,
},
tags: {
feature: "tracing",
@@ -337,6 +337,7 @@ export const getObservationsForTraceFromEventsTable = async (params: {
limit: MAX_OBSERVATIONS_PER_TRACE + 1,
offset: 0,
select: "rows",
selectToolData: false,
tags: { kind: "byTraceId" },
},
);
@@ -394,6 +395,7 @@ export const getObservationsWithModelDataFromEventsTable = async (
async function getObservationsFromEventsTableInternal<T>(
opts: ObservationTableQuery & {
select: "count" | "rows";
selectToolData?: boolean;
tags: Record<string, string>;
},
): Promise<Array<T>> {
@@ -401,6 +403,7 @@ async function getObservationsFromEventsTableInternal<T>(
projectId,
filter,
selectIOAndMetadata,
selectToolData = true,
renderingProps = DEFAULT_RENDERING_PROPS,
limit,
offset,
@@ -461,7 +464,10 @@ async function getObservationsFromEventsTableInternal<T>(
if (opts.select === "count") {
queryBuilder.selectFieldSet("count");
} else {
queryBuilder.selectFieldSet("base", "calculated");
queryBuilder.selectFieldSet(
selectToolData ? "base" : "baseWithoutTools",
"calculated",
);
if (selectIOAndMetadata) {
queryBuilder
.selectIO(
@@ -175,9 +175,7 @@ export const getObservationsForTrace = async <IncludeIO extends boolean>(
prompt_id,
prompt_name,
prompt_version,
tool_definitions,
tool_calls,
tool_call_names,
${includeIO === true ? "tool_definitions, tool_calls, tool_call_names," : ""}
created_at,
updated_at,
event_ts
@@ -5,6 +5,8 @@ import {
AGGREGATABLE_SCORE_TYPES,
AggregatableScoreDataType,
ScoreByDataType,
LISTABLE_SCORE_TYPES,
ListableScoreDataType,
} from "../../domain/scores";
import {
commandClickhouse,
@@ -131,7 +133,7 @@ export const getScoresByIds = async (
scoreId,
source,
scoreScope: "all",
dataTypes: AGGREGATABLE_SCORE_TYPES,
dataTypes: LISTABLE_SCORE_TYPES,
});
};
@@ -249,7 +251,7 @@ export const getScoresForSessions = async <
sessionIds,
limit,
offset,
dataTypes: AGGREGATABLE_SCORE_TYPES,
dataTypes: LISTABLE_SCORE_TYPES,
},
tags: {
feature: "sessions",
@@ -594,7 +596,7 @@ export const getScoresForTraces = async <
) => {
return getScoresForTracesInternal({
...props,
dataTypes: AGGREGATABLE_SCORE_TYPES,
dataTypes: LISTABLE_SCORE_TYPES,
});
};
@@ -673,7 +675,7 @@ export const getScoresForObservations = async <
params: {
projectId: projectId,
observationIds: observationIds,
dataTypes: AGGREGATABLE_SCORE_TYPES,
dataTypes: LISTABLE_SCORE_TYPES,
limit: limit,
offset: offset,
},
@@ -809,7 +811,7 @@ export const getScoresGroupedByNameSourceType = async ({
query: query,
params: {
projectId: projectId,
dataTypes: AGGREGATABLE_SCORE_TYPES,
dataTypes: LISTABLE_SCORE_TYPES,
...(fromTimestamp
? { fromTimestamp: convertDateToClickhouseDateTime(fromTimestamp) }
: {}),
@@ -832,7 +834,7 @@ export const getScoresGroupedByNameSourceType = async ({
return rows.map((row) => ({
name: row.name,
source: row.source as ScoreSourceType,
dataType: row.data_type as AggregatableScoreDataType,
dataType: row.data_type as ListableScoreDataType,
}));
};
@@ -1189,7 +1191,7 @@ const getScoresUiGeneric = async <T>(props: {
input: {
params: {
projectId: projectId,
dataTypes: AGGREGATABLE_SCORE_TYPES,
dataTypes: LISTABLE_SCORE_TYPES,
...(scoresFilterRes ? scoresFilterRes.params : {}),
limit: limit,
offset: offset,
@@ -1393,7 +1395,7 @@ const getScoresUiGenericFromEvents = async <T>(props: {
input: {
params: {
projectId,
dataTypes: AGGREGATABLE_SCORE_TYPES,
dataTypes: LISTABLE_SCORE_TYPES,
...(scoreOnlyFilterRes ? scoreOnlyFilterRes.params : {}),
...tracesCTEParams,
limit,
@@ -1535,7 +1537,7 @@ export const getScoreNames = async (
params: {
projectId: projectId,
...(timestampFilterRes ? timestampFilterRes.params : {}),
dataTypes: AGGREGATABLE_SCORE_TYPES,
dataTypes: LISTABLE_SCORE_TYPES,
},
tags: {
feature: "tracing",
@@ -1564,6 +1566,8 @@ export const getScoreStringValues = async (
);
const timestampFilterRes = chFilter.apply();
// exclude TEXT scores as they are arbitrary by nature and hence have high cardinality
// which in turn can lead to performance issues
const query = `
select
string_value,
@@ -1572,6 +1576,7 @@ export const getScoreStringValues = async (
WHERE s.project_id = {projectId: String}
AND string_value IS NOT NULL
AND string_value != ''
AND s.data_type != 'TEXT'
${timestampFilterRes?.query ? `AND ${timestampFilterRes.query}` : ""}
GROUP BY string_value
ORDER BY count() desc
@@ -1838,7 +1843,7 @@ export const getAggregatedScoresForPrompts = async (
params: {
projectId,
promptIds,
dataTypes: AGGREGATABLE_SCORE_TYPES,
dataTypes: LISTABLE_SCORE_TYPES,
},
tags: {
feature: "tracing",
@@ -1849,7 +1854,7 @@ export const getAggregatedScoresForPrompts = async (
});
return rows.map((row) => ({
...convertScoreAggregation<AggregatableScoreDataType>(row),
...convertScoreAggregation<ListableScoreDataType>(row),
promptId: row.prompt_id,
hasMetadata: !!row.has_metadata,
}));
@@ -1878,7 +1883,7 @@ export const getScoreCountsByProjectInCreationInterval = async ({
params: {
start: convertDateToClickhouseDateTime(start),
end: convertDateToClickhouseDateTime(end),
dataTypes: AGGREGATABLE_SCORE_TYPES,
dataTypes: LISTABLE_SCORE_TYPES,
},
clickhouseConfigs: {
request_timeout: 120000, // 2 minutes timeout
@@ -1957,7 +1962,7 @@ export const getDistinctScoreNames = async (p: {
params: {
projectId,
cutoffCreatedAt: convertDateToClickhouseDateTime(cutoffCreatedAt),
dataTypes: AGGREGATABLE_SCORE_TYPES,
dataTypes: LISTABLE_SCORE_TYPES,
...(scoreTimestampFilter
? {
filterTimestamp: convertDateToClickhouseDateTime(
@@ -2014,7 +2019,7 @@ export const getScoresForBlobStorageExport = function (
projectId,
minTimestamp: convertDateToClickhouseDateTime(minTimestamp),
maxTimestamp: convertDateToClickhouseDateTime(maxTimestamp),
dataTypes: AGGREGATABLE_SCORE_TYPES,
dataTypes: LISTABLE_SCORE_TYPES,
},
tags: {
feature: "blobstorage",
@@ -2087,7 +2092,7 @@ export const getScoresForAnalyticsIntegrations = async function* (
projectId,
minTimestamp: convertDateToClickhouseDateTime(minTimestamp),
maxTimestamp: convertDateToClickhouseDateTime(maxTimestamp),
dataTypes: AGGREGATABLE_SCORE_TYPES,
dataTypes: LISTABLE_SCORE_TYPES,
},
tags: {
feature: "posthog",
@@ -2260,7 +2265,7 @@ export const getScoreCountsByProjectAndDay = async ({
params: {
startDate: convertDateToClickhouseDateTime(startDate),
endDate: convertDateToClickhouseDateTime(endDate),
dataTypes: AGGREGATABLE_SCORE_TYPES,
dataTypes: LISTABLE_SCORE_TYPES,
},
tags: {
feature: "tracing",
@@ -506,6 +506,7 @@ export const getTraceById = async ({
clickhouseFeatureTag = "tracing",
preferredClickhouseService,
excludeInputOutput = false,
excludeMetadata = false,
}: {
traceId: string;
projectId: string;
@@ -516,6 +517,8 @@ export const getTraceById = async ({
preferredClickhouseService?: PreferredClickhouseService;
/** When true, sets input/output columns to empty in the query to reduce database load */
excludeInputOutput?: boolean;
/** When true, sets metadata column to empty in the query to reduce database load */
excludeMetadata?: boolean;
}) => {
const records = await measureAndReturn({
operationName: "getTraceById",
@@ -550,13 +553,14 @@ export const getTraceById = async ({
: renderingProps.truncated
? `leftUTF8(output, ${env.LANGFUSE_SERVER_SIDE_IO_CHAR_LIMIT})`
: "output";
const metadataColumn = excludeMetadata ? "'{}'" : "metadata";
const query = `
SELECT
id,
name as name,
user_id as user_id,
metadata as metadata,
${metadataColumn} as metadata,
release as release,
version as version,
project_id,
+351 -108
View File
@@ -9,7 +9,6 @@ overrides:
nanoid: ^3.3.8
katex: ^0.16.21
tar-fs: ^2.1.2
effect: ^3.20.0
rollup@^4.0.0: ^4.22.4
'@types/node-fetch': ^2.6.13
'@types/react-dom': 19.2.3
@@ -44,8 +43,8 @@ importers:
specifier: ^19.2.4
version: 19.2.4(@types/node@24.10.4)(magicast@0.5.2)
turbo:
specifier: 2.8.20
version: 2.8.20
specifier: 2.9.5
version: 2.9.5
ee:
dependencies:
@@ -111,8 +110,8 @@ importers:
specifier: ^10.1.8
version: 10.1.8(eslint@9.39.4(jiti@2.6.1))
eslint-config-turbo:
specifier: 2.8.20
version: 2.8.20(eslint@9.39.4(jiti@2.6.1))(turbo@2.8.20)
specifier: 2.9.5
version: 2.9.5(eslint@9.39.4(jiti@2.6.1))(turbo@2.9.5)
eslint-plugin-only-warn:
specifier: ^1.1.0
version: 1.2.1
@@ -179,8 +178,8 @@ importers:
specifier: '>=1.0.0 <1.10.0'
version: 1.9.0
'@prisma/client':
specifier: ^6.17.1
version: 6.17.1(prisma@6.17.1(magicast@0.5.2)(typescript@5.9.2))(typescript@5.9.2)
specifier: ^6.19.3
version: 6.19.3(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.2))(typescript@5.9.2)
'@react-email/components':
specifier: ^0.5.1
version: 0.5.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -297,8 +296,8 @@ importers:
specifier: ^3.8.1
version: 3.8.1
prisma:
specifier: ^6.17.1
version: 6.17.1(magicast@0.5.2)(typescript@5.9.2)
specifier: ^6.19.3
version: 6.19.3(magicast@0.5.2)(typescript@5.9.2)
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@24.3.0)(typescript@5.9.2)
@@ -375,14 +374,14 @@ importers:
specifier: ^1.2.3
version: 1.2.3
'@modelcontextprotocol/sdk':
specifier: 1.27.1
version: 1.27.1(@cfworker/json-schema@4.1.1)(zod@4.3.6)
specifier: 1.29.0
version: 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)
'@mui/material':
specifier: ^7.3.9
version: 7.3.9(@emotion/react@11.11.4(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@next-auth/prisma-adapter':
specifier: ^1.0.7
version: 1.0.7(@prisma/client@6.17.1(prisma@6.17.1(magicast@0.5.2)(typescript@5.9.2))(typescript@5.9.2))(next-auth@4.24.13(patch_hash=7b301d2607ee3cd8bba4f6978ba8cdd7a616b59d6e414191da3b38950ce08e66)(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nodemailer@7.0.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
version: 1.0.7(@prisma/client@6.19.3(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.2))(typescript@5.9.2))(next-auth@4.24.13(patch_hash=7b301d2607ee3cd8bba4f6978ba8cdd7a616b59d6e414191da3b38950ce08e66)(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nodemailer@7.0.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
'@opentelemetry/api':
specifier: ^1.9.0
version: 1.9.0
@@ -429,8 +428,8 @@ importers:
specifier: ^2.2.2
version: 2.2.2
'@prisma/instrumentation':
specifier: ^6.17.1
version: 6.17.1(@opentelemetry/api@1.9.0)
specifier: ^6.19.3
version: 6.19.3(@opentelemetry/api@1.9.0)
'@radix-ui/react-accordion':
specifier: ^1.2.12
version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -639,8 +638,8 @@ importers:
specifier: ^2.4.1
version: 2.4.1(react@19.2.4)
prisma:
specifier: ^6.17.1
version: 6.17.1(magicast@0.5.2)(typescript@5.9.2)
specifier: ^6.19.3
version: 6.19.3(magicast@0.5.2)(typescript@5.9.2)
protobufjs:
specifier: ^7.4.0
version: 7.4.0
@@ -889,8 +888,8 @@ importers:
specifier: ^0.53.0
version: 0.53.0(@opentelemetry/api@1.9.0)
'@prisma/instrumentation':
specifier: ^6.17.1
version: 6.17.1(@opentelemetry/api@1.9.0)
specifier: ^6.19.3
version: 6.19.3(@opentelemetry/api@1.9.0)
backoff:
specifier: ^2.5.0
version: 2.5.0
@@ -2809,8 +2808,8 @@ packages:
'@marijn/find-cluster-break@1.0.2':
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
'@modelcontextprotocol/sdk@1.27.1':
resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==}
'@modelcontextprotocol/sdk@1.29.0':
resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==}
engines: {node: '>=18'}
peerDependencies:
'@cfworker/json-schema': ^4.1.1
@@ -3536,8 +3535,8 @@ packages:
'@posthog/core@1.2.2':
resolution: {integrity: sha512-f16Ozx6LIigRG+HsJdt+7kgSxZTHeX5f1JlCGKI1lXcvlZgfsCR338FuMI2QRYXGl+jg/vYFzGOTQBxl90lnBg==}
'@prisma/client@6.17.1':
resolution: {integrity: sha512-zL58jbLzYamjnNnmNA51IOZdbk5ci03KviXCuB0Tydc9btH2kDWsi1pQm2VecviRTM7jGia0OPPkgpGnT3nKvw==}
'@prisma/client@6.19.3':
resolution: {integrity: sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==}
engines: {node: '>=18.18'}
peerDependencies:
prisma: '*'
@@ -3548,26 +3547,26 @@ packages:
typescript:
optional: true
'@prisma/config@6.17.1':
resolution: {integrity: sha512-fs8wY6DsvOCzuiyWVckrVs1LOcbY4LZNz8ki4uUIQ28jCCzojTGqdLhN2Jl5lDnC1yI8/gNIKpsWDM8pLhOdwA==}
'@prisma/config@6.19.3':
resolution: {integrity: sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==}
'@prisma/debug@6.17.1':
resolution: {integrity: sha512-Vf7Tt5Wh9XcndpbmeotuqOMLWPTjEKCsgojxXP2oxE1/xYe7PtnP76hsouG9vis6fctX+TxgmwxTuYi/+xc7dQ==}
'@prisma/debug@6.19.3':
resolution: {integrity: sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==}
'@prisma/engines-version@6.17.1-1.272a37d34178c2894197e17273bf937f25acdeac':
resolution: {integrity: sha512-17140E3huOuD9lMdJ9+SF/juOf3WR3sTJMVyyenzqUPbuH+89nPhSWcrY+Mf7tmSs6HvaO+7S+HkELinn6bhdg==}
'@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7':
resolution: {integrity: sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==}
'@prisma/engines@6.17.1':
resolution: {integrity: sha512-D95Ik3GYZkqZ8lSR4EyFOJ/tR33FcYRP8kK61o+WMsyD10UfJwd7+YielflHfKwiGodcqKqoraWw8ElAgMDbPw==}
'@prisma/engines@6.19.3':
resolution: {integrity: sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==}
'@prisma/fetch-engine@6.17.1':
resolution: {integrity: sha512-AYZiHOs184qkDMiTeshyJCtyL4yERkjfTkJiSJdYuSfc24m94lTNL5+GFinZ6vVz+ktX4NJzHKn1zIFzGTWrWg==}
'@prisma/fetch-engine@6.19.3':
resolution: {integrity: sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==}
'@prisma/get-platform@6.17.1':
resolution: {integrity: sha512-AKEn6fsfz0r482S5KRDFlIGEaq9wLNcgalD1adL+fPcFFblIKs1sD81kY/utrHdqKuVC6E1XSRpegDK3ZLL4Qg==}
'@prisma/get-platform@6.19.3':
resolution: {integrity: sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==}
'@prisma/instrumentation@6.17.1':
resolution: {integrity: sha512-orza4eufBmjAcUAIs7OghKNc3p1vukSvEHr+sv9dT3BeiwvUdLAw/Fns+4B7iYmvPZfdnLHc7XqOF5FldZEeVw==}
'@prisma/instrumentation@6.19.3':
resolution: {integrity: sha512-gwU1D4pxrtBZXINZwSseEal+h8OghvIVZIH3VRv6+vPFAkZk1ykFUVcOhhboBc5WmhoD42NTJjla2i7GFNcZWA==}
peerDependencies:
'@opentelemetry/api': ^1.8
@@ -4453,139 +4452,277 @@ packages:
cpu: [arm]
os: [android]
'@rollup/rollup-android-arm-eabi@4.60.1':
resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==}
cpu: [arm]
os: [android]
'@rollup/rollup-android-arm64@4.60.0':
resolution: {integrity: sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==}
cpu: [arm64]
os: [android]
'@rollup/rollup-android-arm64@4.60.1':
resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==}
cpu: [arm64]
os: [android]
'@rollup/rollup-darwin-arm64@4.60.0':
resolution: {integrity: sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==}
cpu: [arm64]
os: [darwin]
'@rollup/rollup-darwin-arm64@4.60.1':
resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==}
cpu: [arm64]
os: [darwin]
'@rollup/rollup-darwin-x64@4.60.0':
resolution: {integrity: sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==}
cpu: [x64]
os: [darwin]
'@rollup/rollup-darwin-x64@4.60.1':
resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==}
cpu: [x64]
os: [darwin]
'@rollup/rollup-freebsd-arm64@4.60.0':
resolution: {integrity: sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==}
cpu: [arm64]
os: [freebsd]
'@rollup/rollup-freebsd-arm64@4.60.1':
resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==}
cpu: [arm64]
os: [freebsd]
'@rollup/rollup-freebsd-x64@4.60.0':
resolution: {integrity: sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==}
cpu: [x64]
os: [freebsd]
'@rollup/rollup-freebsd-x64@4.60.1':
resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==}
cpu: [x64]
os: [freebsd]
'@rollup/rollup-linux-arm-gnueabihf@4.60.0':
resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-gnueabihf@4.60.1':
resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.60.0':
resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm-musleabihf@4.60.1':
resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.60.0':
resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-gnu@4.60.1':
resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.60.0':
resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-musl@4.60.1':
resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.60.0':
resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-gnu@4.60.1':
resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.60.0':
resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-musl@4.60.1':
resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.60.0':
resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.60.1':
resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.60.0':
resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-musl@4.60.1':
resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.60.0':
resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.60.1':
resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.60.0':
resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-musl@4.60.1':
resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.60.0':
resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-s390x-gnu@4.60.1':
resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.60.0':
resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.60.1':
resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.60.0':
resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-x64-musl@4.60.1':
resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.60.0':
resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==}
cpu: [x64]
os: [openbsd]
'@rollup/rollup-openbsd-x64@4.60.1':
resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==}
cpu: [x64]
os: [openbsd]
'@rollup/rollup-openharmony-arm64@4.60.0':
resolution: {integrity: sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==}
cpu: [arm64]
os: [openharmony]
'@rollup/rollup-openharmony-arm64@4.60.1':
resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==}
cpu: [arm64]
os: [openharmony]
'@rollup/rollup-win32-arm64-msvc@4.60.0':
resolution: {integrity: sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==}
cpu: [arm64]
os: [win32]
'@rollup/rollup-win32-arm64-msvc@4.60.1':
resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==}
cpu: [arm64]
os: [win32]
'@rollup/rollup-win32-ia32-msvc@4.60.0':
resolution: {integrity: sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==}
cpu: [ia32]
os: [win32]
'@rollup/rollup-win32-ia32-msvc@4.60.1':
resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==}
cpu: [ia32]
os: [win32]
'@rollup/rollup-win32-x64-gnu@4.60.0':
resolution: {integrity: sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==}
cpu: [x64]
os: [win32]
'@rollup/rollup-win32-x64-gnu@4.60.1':
resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==}
cpu: [x64]
os: [win32]
'@rollup/rollup-win32-x64-msvc@4.60.0':
resolution: {integrity: sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==}
cpu: [x64]
os: [win32]
'@rollup/rollup-win32-x64-msvc@4.60.1':
resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==}
cpu: [x64]
os: [win32]
'@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
@@ -5254,33 +5391,33 @@ packages:
'@tsconfig/node16@1.0.4':
resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==}
'@turbo/darwin-64@2.8.20':
resolution: {integrity: sha512-FQ9EX1xMU5nbwjxXxM3yU88AQQ6Sqc6S44exPRroMcx9XZHqqppl5ymJF0Ig/z3nvQNwDmz1Gsnvxubo+nXWjQ==}
'@turbo/darwin-64@2.9.5':
resolution: {integrity: sha512-qPxhKsLMQP+9+dsmPgAGidi5uNifD4AoAOnEnljab3Qgn0QZRR31Hp+/CgW3Ia5AanWj6JuLLTBYvuQj4mqTWg==}
cpu: [x64]
os: [darwin]
'@turbo/darwin-arm64@2.8.20':
resolution: {integrity: sha512-Gpyh9ATFGThD6/s9L95YWY54cizg/VRWl2B67h0yofG8BpHf67DFAh9nuJVKG7bY0+SBJDAo5cMur+wOl9YOYw==}
'@turbo/darwin-arm64@2.9.5':
resolution: {integrity: sha512-vkF/9F/l3aWd4bHxTui5Hh0F5xrTZ4e3rbBsc57zA6O8gNbmHN3B6eZ5psAIP2CnJRZ8ZxRjV3WZHeNXMXkPBw==}
cpu: [arm64]
os: [darwin]
'@turbo/linux-64@2.8.20':
resolution: {integrity: sha512-p2QxWUYyYUgUFG0b0kR+pPi8t7c9uaVlRtjTTI1AbCvVqkpjUfCcReBn6DgG/Hu8xrWdKLuyQFaLYFzQskZbcA==}
'@turbo/linux-64@2.9.5':
resolution: {integrity: sha512-z/Get5NUaUxm5HSGFqVMICDRjFNsCUhSc4wnFa/PP1QD0NXCjr7bu9a2EM6md/KMCBW0Qe393Ac+UM7/ryDDTw==}
cpu: [x64]
os: [linux]
'@turbo/linux-arm64@2.8.20':
resolution: {integrity: sha512-Gn5yjlZGLRZWarLWqdQzv0wMqyBNIdq1QLi48F1oY5Lo9kiohuf7BPQWtWxeNVS2NgJ1+nb/DzK1JduYC4AWOA==}
'@turbo/linux-arm64@2.9.5':
resolution: {integrity: sha512-jyBifaNoI5/NheyswomiZXJvjdAdvT7hDRYzQ4meP0DKGvpXUjnqsD+4/J2YSDQ34OHxFkL30FnSCUIVOh2PHw==}
cpu: [arm64]
os: [linux]
'@turbo/windows-64@2.8.20':
resolution: {integrity: sha512-vyaDpYk/8T6Qz5V/X+ihKvKFEZFUoC0oxYpC1sZanK6gaESJlmV3cMRT3Qhcg4D2VxvtC2Jjs9IRkrZGL+exLw==}
'@turbo/windows-64@2.9.5':
resolution: {integrity: sha512-ph24K5uPtvo7UfuyDXnBiB/8XvrO+RQWbbw5zkA/bVNoy9HDiNoIJJj3s62MxT9tjEb6DnPje5PXSz1UR7QAyg==}
cpu: [x64]
os: [win32]
'@turbo/windows-arm64@2.8.20':
resolution: {integrity: sha512-voicVULvUV5yaGXo0Iue13BcHGYW3u0VgqSbfQwBaHbpj1zLjYV4KIe+7fYIo6DO8FVUJzxFps3ODCQG/Wy2Qw==}
'@turbo/windows-arm64@2.9.5':
resolution: {integrity: sha512-6c5RccT/+iR39SdT1G5HyZaD2n57W77o+l0TTfxG/cVlhV94Acyg2gTQW7zUOhW1BeQpBjHzu9x8yVBZwrHh7g==}
cpu: [arm64]
os: [win32]
@@ -7014,8 +7151,8 @@ packages:
peerDependencies:
eslint: '>=7.0.0'
eslint-config-turbo@2.8.20:
resolution: {integrity: sha512-zT17KSqM4gTrChIocN+WfSdjg21QZTCDrcssylFab/wxu6S1EHDbt0JOna9b8rH5OKafZxDuFxKMV8BkulWShg==}
eslint-config-turbo@2.9.5:
resolution: {integrity: sha512-MwAmBEge6WOONTll4BAI1ZbbtD4PrhEaumcu6MNIdrwygMXw2Ne2eV8wxVpj6cnPgyUNE6iS6WdOoi/vPI4YFg==}
peerDependencies:
eslint: '>6.6.0'
turbo: '>2.0.0'
@@ -7102,8 +7239,8 @@ packages:
peerDependencies:
eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7
eslint-plugin-turbo@2.8.20:
resolution: {integrity: sha512-sMDremg73XbX+Imh7iDRS3TAro5jIf7CdzSqrT50wCGhClbiUvmWcLomnX+ldUbfTy+OvtqOeePvTivJMR87eQ==}
eslint-plugin-turbo@2.9.5:
resolution: {integrity: sha512-mwsR2dVy35crMS39TKSKG1xMxfn9M9t/sHXg70GAVqJxoFhsmM1XOcVGQ5w2z7GFUuMP70xUcpSendFzyuuSlQ==}
peerDependencies:
eslint: '>6.6.0'
turbo: '>2.0.0'
@@ -7239,8 +7376,8 @@ packages:
exponential-backoff@3.1.2:
resolution: {integrity: sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA==}
express-rate-limit@8.3.1:
resolution: {integrity: sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==}
express-rate-limit@8.3.2:
resolution: {integrity: sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==}
engines: {node: '>= 16'}
peerDependencies:
express: '>= 4.11'
@@ -7249,9 +7386,6 @@ packages:
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
engines: {node: '>= 18'}
exsolve@1.0.7:
resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==}
exsolve@1.0.8:
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
@@ -9665,8 +9799,8 @@ packages:
peerDependencies:
react: '>=16.0.0'
prisma@6.17.1:
resolution: {integrity: sha512-ac6h0sM1Tg3zu8NInY+qhP/S9KhENVaw9n1BrGKQVFu05JT5yT5Qqqmb8tMRIE3ZXvVj4xcRA5yfrsy4X7Yy5g==}
prisma@6.19.3:
resolution: {integrity: sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==}
engines: {node: '>=18.18'}
hasBin: true
peerDependencies:
@@ -10057,6 +10191,11 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
rollup@4.60.1:
resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
router@2.2.0:
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
engines: {node: '>= 18'}
@@ -10666,8 +10805,8 @@ packages:
ttl-set@1.0.0:
resolution: {integrity: sha512-2fuHn/UR+8Z9HK49r97+p2Ru1b5Eewg2QqPrU14BVCQ9QoyU3+vLLZk2WEiyZ9sgJh6W8G1cZr9I2NBLywAHrA==}
turbo@2.8.20:
resolution: {integrity: sha512-Rb4qk5YT8RUwwdXtkLpkVhNEe/lor6+WV7S5tTlLpxSz6MjV5Qi8jGNn4gS6NAvrYGA/rNrE6YUQM85sCZUDbQ==}
turbo@2.9.5:
resolution: {integrity: sha512-JXNkRe6H6MjSlk5UQRTjyoKX5YN2zlc2632xcSlSFBao5yvbMWTpv9SNolOZlZmUlcDOHuszPLItbKrvcXnnZA==}
hasBin: true
type-check@0.4.0:
@@ -13695,7 +13834,7 @@ snapshots:
'@marijn/find-cluster-break@1.0.2': {}
'@modelcontextprotocol/sdk@1.27.1(@cfworker/json-schema@4.1.1)(zod@4.3.6)':
'@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)':
dependencies:
'@hono/node-server': 1.19.12(hono@4.12.9)
ajv: 8.18.0
@@ -13706,7 +13845,7 @@ snapshots:
eventsource: 3.0.7
eventsource-parser: 3.0.6
express: 5.2.1
express-rate-limit: 8.3.1(express@5.2.1)
express-rate-limit: 8.3.2(express@5.2.1)
hono: 4.12.9
jose: 6.2.2
json-schema-typed: 8.0.2
@@ -13832,9 +13971,9 @@ snapshots:
'@tybys/wasm-util': 0.10.1
optional: true
'@next-auth/prisma-adapter@1.0.7(@prisma/client@6.17.1(prisma@6.17.1(magicast@0.5.2)(typescript@5.9.2))(typescript@5.9.2))(next-auth@4.24.13(patch_hash=7b301d2607ee3cd8bba4f6978ba8cdd7a616b59d6e414191da3b38950ce08e66)(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nodemailer@7.0.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))':
'@next-auth/prisma-adapter@1.0.7(@prisma/client@6.19.3(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.2))(typescript@5.9.2))(next-auth@4.24.13(patch_hash=7b301d2607ee3cd8bba4f6978ba8cdd7a616b59d6e414191da3b38950ce08e66)(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nodemailer@7.0.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))':
dependencies:
'@prisma/client': 6.17.1(prisma@6.17.1(magicast@0.5.2)(typescript@5.9.2))(typescript@5.9.2)
'@prisma/client': 6.19.3(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.2))(typescript@5.9.2)
next-auth: 4.24.13(patch_hash=7b301d2607ee3cd8bba4f6978ba8cdd7a616b59d6e414191da3b38950ce08e66)(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nodemailer@7.0.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@next/env@16.2.1': {}
@@ -14525,12 +14664,12 @@ snapshots:
'@posthog/core@1.2.2': {}
'@prisma/client@6.17.1(prisma@6.17.1(magicast@0.5.2)(typescript@5.9.2))(typescript@5.9.2)':
'@prisma/client@6.19.3(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.2))(typescript@5.9.2)':
optionalDependencies:
prisma: 6.17.1(magicast@0.5.2)(typescript@5.9.2)
prisma: 6.19.3(magicast@0.5.2)(typescript@5.9.2)
typescript: 5.9.2
'@prisma/config@6.17.1(magicast@0.5.2)':
'@prisma/config@6.19.3(magicast@0.5.2)':
dependencies:
c12: 3.1.0(magicast@0.5.2)
deepmerge-ts: 7.1.5
@@ -14539,28 +14678,28 @@ snapshots:
transitivePeerDependencies:
- magicast
'@prisma/debug@6.17.1': {}
'@prisma/debug@6.19.3': {}
'@prisma/engines-version@6.17.1-1.272a37d34178c2894197e17273bf937f25acdeac': {}
'@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7': {}
'@prisma/engines@6.17.1':
'@prisma/engines@6.19.3':
dependencies:
'@prisma/debug': 6.17.1
'@prisma/engines-version': 6.17.1-1.272a37d34178c2894197e17273bf937f25acdeac
'@prisma/fetch-engine': 6.17.1
'@prisma/get-platform': 6.17.1
'@prisma/debug': 6.19.3
'@prisma/engines-version': 7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7
'@prisma/fetch-engine': 6.19.3
'@prisma/get-platform': 6.19.3
'@prisma/fetch-engine@6.17.1':
'@prisma/fetch-engine@6.19.3':
dependencies:
'@prisma/debug': 6.17.1
'@prisma/engines-version': 6.17.1-1.272a37d34178c2894197e17273bf937f25acdeac
'@prisma/get-platform': 6.17.1
'@prisma/debug': 6.19.3
'@prisma/engines-version': 7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7
'@prisma/get-platform': 6.19.3
'@prisma/get-platform@6.17.1':
'@prisma/get-platform@6.19.3':
dependencies:
'@prisma/debug': 6.17.1
'@prisma/debug': 6.19.3
'@prisma/instrumentation@6.17.1(@opentelemetry/api@1.9.0)':
'@prisma/instrumentation@6.19.3(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/instrumentation': 0.53.0(@opentelemetry/api@1.9.0)
@@ -15456,78 +15595,153 @@ snapshots:
'@rollup/rollup-android-arm-eabi@4.60.0':
optional: true
'@rollup/rollup-android-arm-eabi@4.60.1':
optional: true
'@rollup/rollup-android-arm64@4.60.0':
optional: true
'@rollup/rollup-android-arm64@4.60.1':
optional: true
'@rollup/rollup-darwin-arm64@4.60.0':
optional: true
'@rollup/rollup-darwin-arm64@4.60.1':
optional: true
'@rollup/rollup-darwin-x64@4.60.0':
optional: true
'@rollup/rollup-darwin-x64@4.60.1':
optional: true
'@rollup/rollup-freebsd-arm64@4.60.0':
optional: true
'@rollup/rollup-freebsd-arm64@4.60.1':
optional: true
'@rollup/rollup-freebsd-x64@4.60.0':
optional: true
'@rollup/rollup-freebsd-x64@4.60.1':
optional: true
'@rollup/rollup-linux-arm-gnueabihf@4.60.0':
optional: true
'@rollup/rollup-linux-arm-gnueabihf@4.60.1':
optional: true
'@rollup/rollup-linux-arm-musleabihf@4.60.0':
optional: true
'@rollup/rollup-linux-arm-musleabihf@4.60.1':
optional: true
'@rollup/rollup-linux-arm64-gnu@4.60.0':
optional: true
'@rollup/rollup-linux-arm64-gnu@4.60.1':
optional: true
'@rollup/rollup-linux-arm64-musl@4.60.0':
optional: true
'@rollup/rollup-linux-arm64-musl@4.60.1':
optional: true
'@rollup/rollup-linux-loong64-gnu@4.60.0':
optional: true
'@rollup/rollup-linux-loong64-gnu@4.60.1':
optional: true
'@rollup/rollup-linux-loong64-musl@4.60.0':
optional: true
'@rollup/rollup-linux-loong64-musl@4.60.1':
optional: true
'@rollup/rollup-linux-ppc64-gnu@4.60.0':
optional: true
'@rollup/rollup-linux-ppc64-gnu@4.60.1':
optional: true
'@rollup/rollup-linux-ppc64-musl@4.60.0':
optional: true
'@rollup/rollup-linux-ppc64-musl@4.60.1':
optional: true
'@rollup/rollup-linux-riscv64-gnu@4.60.0':
optional: true
'@rollup/rollup-linux-riscv64-gnu@4.60.1':
optional: true
'@rollup/rollup-linux-riscv64-musl@4.60.0':
optional: true
'@rollup/rollup-linux-riscv64-musl@4.60.1':
optional: true
'@rollup/rollup-linux-s390x-gnu@4.60.0':
optional: true
'@rollup/rollup-linux-s390x-gnu@4.60.1':
optional: true
'@rollup/rollup-linux-x64-gnu@4.60.0':
optional: true
'@rollup/rollup-linux-x64-gnu@4.60.1':
optional: true
'@rollup/rollup-linux-x64-musl@4.60.0':
optional: true
'@rollup/rollup-linux-x64-musl@4.60.1':
optional: true
'@rollup/rollup-openbsd-x64@4.60.0':
optional: true
'@rollup/rollup-openbsd-x64@4.60.1':
optional: true
'@rollup/rollup-openharmony-arm64@4.60.0':
optional: true
'@rollup/rollup-openharmony-arm64@4.60.1':
optional: true
'@rollup/rollup-win32-arm64-msvc@4.60.0':
optional: true
'@rollup/rollup-win32-arm64-msvc@4.60.1':
optional: true
'@rollup/rollup-win32-ia32-msvc@4.60.0':
optional: true
'@rollup/rollup-win32-ia32-msvc@4.60.1':
optional: true
'@rollup/rollup-win32-x64-gnu@4.60.0':
optional: true
'@rollup/rollup-win32-x64-gnu@4.60.1':
optional: true
'@rollup/rollup-win32-x64-msvc@4.60.0':
optional: true
'@rollup/rollup-win32-x64-msvc@4.60.1':
optional: true
'@rtsao/scc@1.1.0': {}
'@selderee/plugin-htmlparser2@0.11.0':
@@ -16358,22 +16572,22 @@ snapshots:
'@tsconfig/node16@1.0.4': {}
'@turbo/darwin-64@2.8.20':
'@turbo/darwin-64@2.9.5':
optional: true
'@turbo/darwin-arm64@2.8.20':
'@turbo/darwin-arm64@2.9.5':
optional: true
'@turbo/linux-64@2.8.20':
'@turbo/linux-64@2.9.5':
optional: true
'@turbo/linux-arm64@2.8.20':
'@turbo/linux-arm64@2.9.5':
optional: true
'@turbo/windows-64@2.8.20':
'@turbo/windows-64@2.9.5':
optional: true
'@turbo/windows-arm64@2.8.20':
'@turbo/windows-arm64@2.9.5':
optional: true
'@tybys/wasm-util@0.10.1':
@@ -17456,7 +17670,7 @@ snapshots:
confbox: 0.2.4
defu: 6.1.4
dotenv: 16.6.1
exsolve: 1.0.7
exsolve: 1.0.8
giget: 2.0.0
jiti: 2.6.1
ohash: 2.0.11
@@ -18349,11 +18563,11 @@ snapshots:
dependencies:
eslint: 9.39.4(jiti@2.6.1)
eslint-config-turbo@2.8.20(eslint@9.39.4(jiti@2.6.1))(turbo@2.8.20):
eslint-config-turbo@2.9.5(eslint@9.39.4(jiti@2.6.1))(turbo@2.9.5):
dependencies:
eslint: 9.39.4(jiti@2.6.1)
eslint-plugin-turbo: 2.8.20(eslint@9.39.4(jiti@2.6.1))(turbo@2.8.20)
turbo: 2.8.20
eslint-plugin-turbo: 2.9.5(eslint@9.39.4(jiti@2.6.1))(turbo@2.9.5)
turbo: 2.9.5
eslint-import-resolver-node@0.3.9:
dependencies:
@@ -18509,11 +18723,11 @@ snapshots:
string.prototype.matchall: 4.0.12
string.prototype.repeat: 1.0.0
eslint-plugin-turbo@2.8.20(eslint@9.39.4(jiti@2.6.1))(turbo@2.8.20):
eslint-plugin-turbo@2.9.5(eslint@9.39.4(jiti@2.6.1))(turbo@2.9.5):
dependencies:
dotenv: 16.0.3
eslint: 9.39.4(jiti@2.6.1)
turbo: 2.8.20
turbo: 2.9.5
eslint-scope@5.1.1:
dependencies:
@@ -18679,7 +18893,7 @@ snapshots:
exponential-backoff@3.1.2: {}
express-rate-limit@8.3.1(express@5.2.1):
express-rate-limit@8.3.2(express@5.2.1):
dependencies:
express: 5.2.1
ip-address: 10.1.0
@@ -18717,8 +18931,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
exsolve@1.0.7: {}
exsolve@1.0.8: {}
extend@3.0.2: {}
@@ -21617,10 +21829,10 @@ snapshots:
clsx: 2.1.1
react: 19.2.4
prisma@6.17.1(magicast@0.5.2)(typescript@5.9.2):
prisma@6.19.3(magicast@0.5.2)(typescript@5.9.2):
dependencies:
'@prisma/config': 6.17.1(magicast@0.5.2)
'@prisma/engines': 6.17.1
'@prisma/config': 6.19.3(magicast@0.5.2)
'@prisma/engines': 6.19.3
optionalDependencies:
typescript: 5.9.2
transitivePeerDependencies:
@@ -22134,6 +22346,37 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.60.0
fsevents: 2.3.3
rollup@4.60.1:
dependencies:
'@types/estree': 1.0.8
optionalDependencies:
'@rollup/rollup-android-arm-eabi': 4.60.1
'@rollup/rollup-android-arm64': 4.60.1
'@rollup/rollup-darwin-arm64': 4.60.1
'@rollup/rollup-darwin-x64': 4.60.1
'@rollup/rollup-freebsd-arm64': 4.60.1
'@rollup/rollup-freebsd-x64': 4.60.1
'@rollup/rollup-linux-arm-gnueabihf': 4.60.1
'@rollup/rollup-linux-arm-musleabihf': 4.60.1
'@rollup/rollup-linux-arm64-gnu': 4.60.1
'@rollup/rollup-linux-arm64-musl': 4.60.1
'@rollup/rollup-linux-loong64-gnu': 4.60.1
'@rollup/rollup-linux-loong64-musl': 4.60.1
'@rollup/rollup-linux-ppc64-gnu': 4.60.1
'@rollup/rollup-linux-ppc64-musl': 4.60.1
'@rollup/rollup-linux-riscv64-gnu': 4.60.1
'@rollup/rollup-linux-riscv64-musl': 4.60.1
'@rollup/rollup-linux-s390x-gnu': 4.60.1
'@rollup/rollup-linux-x64-gnu': 4.60.1
'@rollup/rollup-linux-x64-musl': 4.60.1
'@rollup/rollup-openbsd-x64': 4.60.1
'@rollup/rollup-openharmony-arm64': 4.60.1
'@rollup/rollup-win32-arm64-msvc': 4.60.1
'@rollup/rollup-win32-ia32-msvc': 4.60.1
'@rollup/rollup-win32-x64-gnu': 4.60.1
'@rollup/rollup-win32-x64-msvc': 4.60.1
fsevents: 2.3.3
router@2.2.0:
dependencies:
debug: 4.4.3
@@ -22786,14 +23029,14 @@ snapshots:
dependencies:
fast-fifo: 1.3.2
turbo@2.8.20:
turbo@2.9.5:
optionalDependencies:
'@turbo/darwin-64': 2.8.20
'@turbo/darwin-arm64': 2.8.20
'@turbo/linux-64': 2.8.20
'@turbo/linux-arm64': 2.8.20
'@turbo/windows-64': 2.8.20
'@turbo/windows-arm64': 2.8.20
'@turbo/darwin-64': 2.9.5
'@turbo/darwin-arm64': 2.9.5
'@turbo/linux-64': 2.9.5
'@turbo/linux-arm64': 2.9.5
'@turbo/windows-64': 2.9.5
'@turbo/windows-arm64': 2.9.5
type-check@0.4.0:
dependencies:
@@ -23100,7 +23343,7 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
postcss: 8.5.8
rollup: 4.60.0
rollup: 4.60.1
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 24.3.0
+18
View File
@@ -61,6 +61,24 @@ minimumReleaseAgeExclude:
- "langfuse@3.38.20"
- "langfuse-langchain@3.38.20"
- "langfuse-core@3.38.20"
- "@modelcontextprotocol/sdk@1.29.0"
- "@prisma/instrumentation@6.19.3"
- "prisma@6.19.3"
- "@prisma/client@6.19.3"
- "@prisma/config@6.19.3"
- "@prisma/engines@6.19.3"
- "@prisma/debug@6.19.3"
- "@prisma/fetch-engine@6.19.3"
- "@prisma/get-platform@6.19.3"
- "eslint-config-turbo@2.9.5"
- "eslint-plugin-turbo@2.9.5"
- "turbo@2.9.5"
- "@turbo/darwin-64@2.9.5"
- "@turbo/darwin-arm64@2.9.5"
- "@turbo/windows-64@2.9.5"
- "@turbo/windows-arm64@2.9.5"
- "@turbo/linux-64@2.9.5"
- "@turbo/linux-arm64@2.9.5"
allowBuilds:
"@prisma/client": true
"@prisma/engines": true
+4 -4
View File
@@ -7,7 +7,7 @@ RUN apk update && apk upgrade --no-cache libcrypto3 libssl3 libc6-compat busybox
FROM --platform=${TARGETPLATFORM:-linux/amd64} alpine AS build-base
# Pin turbo to avoid nondeterministic prune output from future patch releases.
RUN npm install turbo@2.8.20 --global
RUN npm install turbo@2.9.5 --global
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
@@ -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
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "3.164.0",
"version": "3.167.0",
"private": true,
"license": "MIT",
"engines": {
@@ -47,7 +47,7 @@
"@langfuse/ee": "workspace:*",
"@langfuse/shared": "workspace:*",
"@lezer/highlight": "^1.2.3",
"@modelcontextprotocol/sdk": "1.27.1",
"@modelcontextprotocol/sdk": "1.29.0",
"@mui/material": "^7.3.9",
"@next-auth/prisma-adapter": "^1.0.7",
"@opentelemetry/api": "^1.9.0",
@@ -65,7 +65,7 @@
"@opentelemetry/sdk-trace-base": "^1.26.0",
"@opentelemetry/sdk-trace-node": "^1.26.0",
"@paralleldrive/cuid2": "^2.2.2",
"@prisma/instrumentation": "^6.17.1",
"@prisma/instrumentation": "^6.19.3",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
@@ -135,7 +135,7 @@
"posthog-node": "^5.8.4",
"prexit": "^2.2.0",
"prism-react-renderer": "^2.4.1",
"prisma": "^6.17.1",
"prisma": "^6.19.3",
"protobufjs": "^7.4.0",
"rate-limiter-flexible": "^5.0.4",
"react": "19.2.4",
+98 -8
View File
@@ -5639,6 +5639,19 @@ paths:
required: true
schema:
type: string
- name: fields
in: query
description: >-
Comma-separated list of fields to include in the response. Available
field groups: 'core' (always included), 'io' (input, output,
metadata), 'scores', 'observations', 'metrics'. If not specified,
all fields are returned. Example: 'core,scores,metrics'. Note:
Excluded 'observations' or 'scores' fields return empty arrays;
excluded 'metrics' returns -1 for 'totalCost' and 'latency'.
required: false
schema:
type: string
nullable: true
responses:
'200':
description: ''
@@ -6353,6 +6366,12 @@ components:
description: >-
Custom start date for exports (required when exportMode is
FROM_CUSTOM_DATE)
compressed:
type: boolean
nullable: true
description: >-
Enable gzip compression for exported files (.csv.gz, .json.gz,
.jsonl.gz). Defaults to true.
required:
- projectId
- type
@@ -6399,6 +6418,8 @@ components:
type: string
format: date-time
nullable: true
compressed:
type: boolean
nextSyncAt:
type: string
format: date-time
@@ -6432,6 +6453,7 @@ components:
- forcePathStyle
- fileType
- exportMode
- compressed
- createdAt
- updatedAt
BlobStorageIntegrationsResponse:
@@ -7283,6 +7305,17 @@ components:
- stringValue
allOf:
- $ref: '#/components/schemas/BaseScoreV1'
TextScoreV1:
title: TextScoreV1
type: object
properties:
stringValue:
type: string
description: The text content of the score (1-500 characters)
required:
- stringValue
allOf:
- $ref: '#/components/schemas/BaseScoreV1'
ScoreV1:
title: ScoreV1
oneOf:
@@ -7319,6 +7352,17 @@ components:
- $ref: '#/components/schemas/BooleanScoreV1'
required:
- dataType
- type: object
allOf:
- type: object
properties:
dataType:
type: string
enum:
- TEXT
- $ref: '#/components/schemas/TextScoreV1'
required:
- dataType
BaseScore:
title: BaseScore
type: object
@@ -7460,6 +7504,17 @@ components:
- stringValue
allOf:
- $ref: '#/components/schemas/BaseScore'
TextScore:
title: TextScore
type: object
properties:
stringValue:
type: string
description: The text content of the score (1-500 characters)
required:
- stringValue
allOf:
- $ref: '#/components/schemas/BaseScore'
Score:
title: Score
oneOf:
@@ -7507,6 +7562,17 @@ components:
- $ref: '#/components/schemas/CorrectionScore'
required:
- dataType
- type: object
allOf:
- type: object
properties:
dataType:
type: string
enum:
- TEXT
- $ref: '#/components/schemas/TextScore'
required:
- dataType
CreateScoreValue:
title: CreateScoreValue
oneOf:
@@ -7514,8 +7580,8 @@ components:
format: double
- type: string
description: >-
The value of the score. Must be passed as string for categorical scores,
and numeric for boolean and numeric scores
The value of the score. Must be passed as string for categorical and
text scores, and numeric for boolean and numeric scores
Comment:
title: Comment
type: object
@@ -8235,6 +8301,7 @@ components:
- NUMERIC
- BOOLEAN
- CATEGORICAL
- TEXT
ScoreDataType:
title: ScoreDataType
type: string
@@ -8243,6 +8310,7 @@ components:
- BOOLEAN
- CATEGORICAL
- CORRECTION
- TEXT
DeleteDatasetItemResponse:
title: DeleteDatasetItemResponse
type: object
@@ -8869,9 +8937,10 @@ components:
value:
$ref: '#/components/schemas/CreateScoreValue'
description: >-
The value of the score. Must be passed as string for categorical
scores, and numeric for boolean and numeric scores. Boolean score
values must equal either 1 or 0 (true or false)
The value of the score. Must be passed as string for categorical and
text scores, and numeric for boolean and numeric scores. Boolean
score values must equal either 1 or 0 (true or false). Text score
values must be between 1 and 500 characters.
comment:
type: string
nullable: true
@@ -9188,9 +9257,10 @@ components:
value:
$ref: '#/components/schemas/CreateScoreValue'
description: >-
The value of the score. Must be passed as string for categorical
scores, and numeric for boolean and numeric scores. Boolean score
values must equal either 1 or 0 (true or false)
The value of the score. Must be passed as string for categorical and
text scores, and numeric for boolean and numeric scores. Boolean
score values must equal either 1 or 0 (true or false). Text score
values must be between 1 and 500 characters.
comment:
type: string
nullable: true
@@ -10821,6 +10891,15 @@ components:
nullable: true
allOf:
- $ref: '#/components/schemas/CorrectionScore'
GetScoresResponseDataText:
title: GetScoresResponseDataText
type: object
properties:
trace:
$ref: '#/components/schemas/GetScoresResponseTraceData'
nullable: true
allOf:
- $ref: '#/components/schemas/TextScore'
GetScoresResponseData:
title: GetScoresResponseData
oneOf:
@@ -10868,6 +10947,17 @@ components:
- $ref: '#/components/schemas/GetScoresResponseDataCorrection'
required:
- dataType
- type: object
allOf:
- type: object
properties:
dataType:
type: string
enum:
- TEXT
- $ref: '#/components/schemas/GetScoresResponseDataText'
required:
- dataType
GetScoresResponse:
title: GetScoresResponse
type: object
@@ -320,21 +320,17 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
).toBe(true);
});
it("should return 404 when getting an ARCHIVED dataset item by id", async () => {
it("should return archived dataset item when getting by id", async () => {
const datasetName = `dataset-archived-by-id-${v4()}`;
// Create dataset
await makeZodVerifiedAPICall(
PostDatasetsV1Response,
"POST",
"/api/public/datasets",
{
name: datasetName,
},
{ name: datasetName },
auth,
);
// Create an archived dataset item
const archivedItem = await makeZodVerifiedAPICall(
PostDatasetItemsV1Response,
"POST",
@@ -350,16 +346,29 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
expect(archivedItem.status).toBe(200);
expect(archivedItem.body.status).toBe("ARCHIVED");
// Try to get the archived item by id - should return 404
const getArchivedItem = await makeAPICall(
const getArchivedItem = await makeZodVerifiedAPICall(
GetDatasetItemV1Response,
"GET",
`/api/public/dataset-items/archived-item-by-id`,
undefined,
auth,
);
expect(getArchivedItem.status).toBe(404);
expect(getArchivedItem.status).toBe(200);
expect(getArchivedItem.body.id).toBe("archived-item-by-id");
expect(getArchivedItem.body.status).toBe("ARCHIVED");
});
it("should return active dataset item when getting by id", async () => {
const datasetName = `dataset-active-by-id-${v4()}`;
await makeZodVerifiedAPICall(
PostDatasetsV1Response,
"POST",
"/api/public/datasets",
{ name: datasetName },
auth,
);
// Create an active item to verify GET still works for active items
const activeItem = await makeZodVerifiedAPICall(
PostDatasetItemsV1Response,
"POST",
@@ -374,7 +383,6 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
);
expect(activeItem.status).toBe(200);
// Get the active item by id - should succeed
const getActiveItem = await makeZodVerifiedAPICall(
GetDatasetItemV1Response,
"GET",
@@ -1,6 +1,7 @@
import {
CTEQueryBuilder,
EventsAggregationQueryBuilder,
EventsQueryBuilder,
} from "@langfuse/shared/src/server";
describe("CTEQueryBuilder", () => {
@@ -121,3 +122,27 @@ describe("CTEQueryBuilder", () => {
expect(params.param3).toBe("value3");
});
});
describe("EventsQueryBuilder", () => {
it("should allow list queries to omit tool payload columns", () => {
const slimQuery = new EventsQueryBuilder({
projectId: "test-project",
})
.selectFieldSet("baseWithoutTools", "calculated")
.buildWithParams().query;
const defaultQuery = new EventsQueryBuilder({
projectId: "test-project",
})
.selectFieldSet("base", "calculated")
.buildWithParams().query;
expect(slimQuery).not.toContain('e.tool_definitions as "tool_definitions"');
expect(slimQuery).not.toContain('e.tool_calls as "tool_calls"');
expect(slimQuery).not.toContain('e.tool_call_names as "tool_call_names"');
expect(defaultQuery).toContain('e.tool_definitions as "tool_definitions"');
expect(defaultQuery).toContain('e.tool_calls as "tool_calls"');
expect(defaultQuery).toContain('e.tool_call_names as "tool_call_names"');
});
});
@@ -1,24 +1,70 @@
/** @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;
let orgId: string;
let session: Session;
let caller: ReturnType<typeof appRouter.createCaller>;
const createCallerForProjectRole = (
projectRole: "ADMIN" | "MEMBER" | "VIEWER",
) => {
const limitedSession: Session = {
...session,
user: {
...session.user!,
admin: false,
organizations: [
{
...session.user!.organizations[0],
role: "MEMBER",
projects: [
{
...session.user!.organizations[0].projects[0],
role: projectRole,
},
],
},
],
},
};
const limitedCtx = createInnerTRPCContext({
session: limitedSession,
headers: {},
});
return appRouter.createCaller({ ...limitedCtx, prisma });
};
beforeEach(async () => {
const setup = await createOrgProjectAndApiKey();
projectId = setup.projectId;
orgId = setup.orgId;
mockFetchLLMCompletion.mockReset().mockResolvedValue({});
const session: Session = {
session = {
expires: "1",
user: {
id: "user-1",
@@ -141,6 +187,159 @@ describe("llmApiKey.all RPC", () => {
expect(secretKey).toBeUndefined();
});
it("should require llmApiKeys:create access for testing a new llm api key", async () => {
const memberCaller = createCallerForProjectRole("MEMBER");
await expect(
memberCaller.llmApiKey.test({
projectId,
provider: "openai",
adapter: LLMAdapter.OpenAI,
secretKey: "sk-test",
baseURL: "https://attacker.example.com/v1",
}),
).rejects.toThrow("User does not have access to this resource or action");
});
it("should require llmApiKeys:create access for testing an existing llm api key", async () => {
await caller.llmApiKey.create({
projectId,
provider: "openai",
adapter: LLMAdapter.OpenAI,
secretKey: "sk-test",
baseURL: "https://api.openai.com/v1",
});
const existingKey = await prisma.llmApiKeys.findFirstOrThrow({
where: {
projectId,
provider: "openai",
},
});
const memberCaller = createCallerForProjectRole("MEMBER");
await expect(
memberCaller.llmApiKey.testUpdate({
id: existingKey.id,
projectId,
provider: "openai",
adapter: LLMAdapter.OpenAI,
baseURL: "https://attacker.example.com/v1",
}),
).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";
@@ -69,7 +69,7 @@ const setupTriggerAndAction = async (projectId: string) => {
id: v4(),
projectId: projectId,
eventSource: "prompt",
eventActions: ["updated"],
eventActions: [],
filter: [],
status: "ACTIVE",
},
@@ -2,11 +2,15 @@ import { prisma } from "@langfuse/shared/src/db";
import {
createScoresCh,
getScoreById,
getScoresByIds,
getScoresGroupedByNameSourceType,
getScoresUiTable,
getScoresForTraces,
getScoresForObservations,
getScoresForSessions,
getScoresForExperiments,
getTraceScoresForDatasetRuns,
getScoreNames,
createTracesCh,
createObservationsCh,
createTrace,
@@ -17,6 +21,7 @@ import {
createDatasetRunScore,
createSessionScore,
createOrgProjectAndApiKey,
getScoreStringValues,
} from "@langfuse/shared/src/server";
import { v4 } from "uuid";
@@ -798,4 +803,350 @@ describe("Clickhouse Scores Repository Test", () => {
expect(result[0].metadata).toEqual({});
});
});
describe("TEXT scores", () => {
it("should round-trip a TEXT score through ClickHouse", async () => {
const scoreId = v4();
const traceId = v4();
const score = createTraceScore({
id: scoreId,
project_id: projectId,
trace_id: traceId,
name: "free-form-score",
value: 0,
string_value: "This is free text feedback",
data_type: "TEXT",
source: "ANNOTATION",
});
await createScoresCh([score]);
const result = await getScoreById({ projectId, scoreId });
expect(result).toBeDefined();
expect(result!.dataType).toBe("TEXT");
expect(result!.stringValue).toBe("This is free text feedback");
expect(result!.value).toBe(0);
expect(result!.source).toBe("ANNOTATION");
});
it("should include TEXT scores in getScoresGroupedByNameSourceType", async () => {
const isolatedProjectId = v4();
const traceId = v4();
const numericScore = createTraceScore({
project_id: isolatedProjectId,
trace_id: traceId,
name: "numeric-score",
data_type: "NUMERIC",
value: 42,
source: "API",
});
const textScore = createTraceScore({
project_id: isolatedProjectId,
trace_id: traceId,
name: "free-form-score",
data_type: "TEXT",
value: 0,
string_value: "Some feedback text",
source: "ANNOTATION",
});
await createScoresCh([numericScore, textScore]);
const result = await getScoresGroupedByNameSourceType({
projectId: isolatedProjectId,
filter: [],
});
expect(result).toHaveLength(2);
expect(result).toEqual([
{
name: "free-form-score",
source: "ANNOTATION",
dataType: "TEXT",
},
{
name: "numeric-score",
source: "API",
dataType: "NUMERIC",
},
]);
});
it("should exclude TEXT string values from getScoreStringValues", async () => {
const isolatedProjectId = v4();
const traceId = v4();
const categoricalScore = createTraceScore({
project_id: isolatedProjectId,
trace_id: traceId,
name: "cat-score",
data_type: "CATEGORICAL",
value: 1,
string_value: "Good",
source: "API",
});
const textScore = createTraceScore({
project_id: isolatedProjectId,
trace_id: traceId,
name: "free-text-score",
data_type: "TEXT",
value: 0,
string_value: "This should not appear in filter options",
source: "ANNOTATION",
});
await createScoresCh([categoricalScore, textScore]);
const result = await getScoreStringValues(isolatedProjectId, []);
const stringValues = result.map((r) => r.value);
expect(stringValues).toContain("Good");
expect(stringValues).not.toContain(
"This should not appear in filter options",
);
});
});
describe("score data type coverage", () => {
let isolatedProjectId: string;
const traceId = v4();
const observationId = v4();
const sessionId = v4();
const scoreIds: string[] = [];
beforeAll(async () => {
const { projectId: pid } = await createOrgProjectAndApiKey();
isolatedProjectId = pid;
const trace = createTrace({
id: traceId,
project_id: isolatedProjectId,
session_id: sessionId,
});
await createTracesCh([trace]);
const obs = createObservation({
id: observationId,
trace_id: traceId,
project_id: isolatedProjectId,
});
await createObservationsCh([obs]);
const scores = [
createTraceScore({
project_id: isolatedProjectId,
trace_id: traceId,
observation_id: observationId,
name: "numeric",
data_type: "NUMERIC",
value: 1,
source: "API",
}),
createTraceScore({
project_id: isolatedProjectId,
trace_id: traceId,
observation_id: observationId,
name: "categorical",
data_type: "CATEGORICAL",
value: 0,
string_value: "Good",
source: "API",
}),
createTraceScore({
project_id: isolatedProjectId,
trace_id: traceId,
observation_id: observationId,
name: "boolean",
data_type: "BOOLEAN",
value: 1,
string_value: "True",
source: "API",
}),
createTraceScore({
project_id: isolatedProjectId,
trace_id: traceId,
observation_id: observationId,
name: "free-form",
data_type: "TEXT",
value: 0,
string_value: "Some feedback",
source: "ANNOTATION",
}),
];
scoreIds.push(...scores.map((s) => s.id));
const sessionScores = [
createSessionScore({
project_id: isolatedProjectId,
session_id: sessionId,
name: "session-numeric",
data_type: "NUMERIC",
value: 5,
source: "API",
}),
createSessionScore({
project_id: isolatedProjectId,
session_id: sessionId,
name: "session-free-form",
data_type: "TEXT",
value: 0,
string_value: "Session feedback",
source: "ANNOTATION",
}),
];
await createScoresCh([...scores, ...sessionScores]);
});
it("getScoresByIds should return all non-CORRECTION types", async () => {
const result = await getScoresByIds(isolatedProjectId, scoreIds);
expect(result).toHaveLength(4);
});
it("getScoresForTraces should return all non-CORRECTION types", async () => {
const result = await getScoresForTraces({
projectId: isolatedProjectId,
traceIds: [traceId],
});
expect(result).toHaveLength(4);
});
it("getScoresForObservations should return all non-CORRECTION types", async () => {
const result = await getScoresForObservations({
projectId: isolatedProjectId,
observationIds: [observationId],
});
expect(result).toHaveLength(4);
});
it("getScoresForSessions should return all non-CORRECTION types", async () => {
const result = await getScoresForSessions({
projectId: isolatedProjectId,
sessionIds: [sessionId],
});
expect(result).toHaveLength(2);
});
it("getScoreNames should return names of all non-CORRECTION types", async () => {
const result = await getScoreNames(isolatedProjectId, []);
const names = result.map((r) => r.name);
expect(names).toContain("numeric");
expect(names).toContain("categorical");
expect(names).toContain("boolean");
expect(names).toContain("free-form");
});
it("getScoresGroupedByNameSourceType should return all listable score types", async () => {
const result = await getScoresGroupedByNameSourceType({
projectId: isolatedProjectId,
filter: [],
});
const dataTypes = result.map((r) => r.dataType);
expect(dataTypes).toContain("NUMERIC");
expect(dataTypes).toContain("CATEGORICAL");
expect(dataTypes).toContain("BOOLEAN");
expect(dataTypes).toContain("TEXT");
});
it("getScoresForExperiments should exclude TEXT scores", async () => {
const dataset = await prisma.dataset.create({
data: { projectId: isolatedProjectId, name: v4() },
});
const datasetRun = await prisma.datasetRuns.create({
data: {
projectId: isolatedProjectId,
name: v4(),
datasetId: dataset.id,
},
});
const scores = [
createDatasetRunScore({
project_id: isolatedProjectId,
dataset_run_id: datasetRun.id,
name: "run-numeric",
data_type: "NUMERIC",
value: 1,
}),
createDatasetRunScore({
project_id: isolatedProjectId,
dataset_run_id: datasetRun.id,
name: "run-free-form",
data_type: "TEXT",
value: 0,
string_value: "Should be excluded",
}),
];
await createScoresCh(scores);
const result = await getScoresForExperiments({
projectId: isolatedProjectId,
runIds: [datasetRun.id],
});
expect(result).toHaveLength(1);
expect(result[0].dataType).toBe("NUMERIC");
});
it("getTraceScoresForDatasetRuns should exclude TEXT scores", async () => {
const dataset = await prisma.dataset.create({
data: { projectId: isolatedProjectId, name: v4() },
});
const datasetRun = await prisma.datasetRuns.create({
data: {
projectId: isolatedProjectId,
name: v4(),
datasetId: dataset.id,
},
});
const runTraceId = v4();
const runTrace = createTrace({
id: runTraceId,
project_id: isolatedProjectId,
});
await createTracesCh([runTrace]);
const datasetRunItem = createDatasetRunItem({
project_id: isolatedProjectId,
dataset_run_id: datasetRun.id,
dataset_id: dataset.id,
dataset_run_name: datasetRun.name,
dataset_item_id: v4(),
trace_id: runTraceId,
});
await createDatasetRunItemsCh([datasetRunItem]);
const scores = [
createTraceScore({
project_id: isolatedProjectId,
trace_id: runTraceId,
name: "trace-boolean",
data_type: "BOOLEAN",
value: 1,
string_value: "True",
}),
createTraceScore({
project_id: isolatedProjectId,
trace_id: runTraceId,
name: "trace-free-form",
data_type: "TEXT",
value: 0,
string_value: "Should be excluded",
}),
];
await createScoresCh(scores);
const result = await getTraceScoresForDatasetRuns(isolatedProjectId, [
datasetRun.id,
]);
expect(result).toHaveLength(1);
expect(result[0].dataType).toBe("BOOLEAN");
});
});
});
@@ -257,6 +257,34 @@ describe("/api/public/score-configs API Endpoint", () => {
]);
});
it("should POST a text score config", async () => {
const postScoreConfig = await makeZodVerifiedAPICall(
PostScoreConfigResponse,
"POST",
"/api/public/score-configs",
{
name: "text-config-name",
dataType: "TEXT",
},
auth,
);
const scoreConfig = await makeZodVerifiedAPICall(
GetScoreConfigResponse,
"GET",
`/api/public/score-configs/${postScoreConfig.body.id}`,
undefined,
auth,
);
expect(postScoreConfig.status).toBe(200);
expect(scoreConfig.body.name).toBe("text-config-name");
expect(scoreConfig.body.dataType).toBe("TEXT");
expect(scoreConfig.body.categories).toBeFalsy();
expect(scoreConfig.body.maxValue).toBeFalsy();
expect(scoreConfig.body.minValue).toBeFalsy();
});
it("should fail POST of numeric score config with invalid range", async () => {
try {
await makeZodVerifiedAPICall(
@@ -75,6 +75,51 @@ describe("/api/public/scores API Endpoint", () => {
});
});
it("should GET a text score", async () => {
const { projectId, auth } = await createOrgProjectAndApiKey();
const scoreId = v4();
const traceId = v4();
const score = createTraceScore({
id: scoreId,
project_id: projectId,
trace_id: traceId,
name: "Text Score",
timestamp: Date.now(),
value: 0,
string_value: "Great explanation",
source: "API",
comment: "comment",
data_type: "TEXT" as const,
created_at: Date.now(),
updated_at: Date.now(),
event_ts: Date.now(),
is_deleted: 0,
});
await createScoresCh([score]);
const getScore = await makeZodVerifiedAPICall(
GetScoreResponseV1,
"GET",
`/api/public/scores/${scoreId}`,
undefined,
auth,
);
expect(getScore.status).toBe(200);
expect(getScore.body).toMatchObject({
id: scoreId,
name: "Text Score",
stringValue: "Great explanation",
comment: "comment",
source: "API",
traceId,
dataType: "TEXT",
});
expect(getScore.body).not.toHaveProperty("value");
});
it("should GET score with minimal score data and minimal trace data", async () => {
const { projectId, auth } = await createOrgProjectAndApiKey();
@@ -422,6 +467,8 @@ describe("/api/public/scores API Endpoint", () => {
const scoreId_5 = v4();
const scoreId_6 = v4();
const scoreId_7 = v4();
const textScoreId_1 = v4();
const textScoreId_2 = v4();
let authentication: string;
let newProjectId: string;
@@ -530,6 +577,26 @@ describe("/api/public/scores API Endpoint", () => {
environment: "production",
});
const textScore1 = createTraceScore({
id: textScoreId_1,
project_id: newProjectId,
trace_id: traceId_2,
name: "text-score-name",
data_type: "TEXT",
string_value: "text-value-1",
value: 0,
});
const textScore2 = createTraceScore({
id: textScoreId_2,
project_id: newProjectId,
trace_id: traceId_3,
name: "text-score-name",
data_type: "TEXT",
string_value: "text-value-2",
value: 0,
});
const sessionScore1 = createSessionScore({
id: scoreId_6,
project_id: newProjectId,
@@ -554,6 +621,8 @@ describe("/api/public/scores API Endpoint", () => {
score3,
score4,
score5,
textScore1,
textScore2,
sessionScore1,
sessionScore2,
]);
@@ -571,7 +640,7 @@ describe("/api/public/scores API Endpoint", () => {
expect(getAllScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 5, // 7 scores in total, but only 5 are trace scores
totalItems: 7, // 9 scores in total, but only 7 are trace scores
totalPages: 1,
});
for (const val of getAllScore.body.data) {
@@ -631,6 +700,31 @@ describe("/api/public/scores API Endpoint", () => {
}
});
it("get all scores for text data type", async () => {
const getAllScore = await makeZodVerifiedAPICall(
GetScoresResponseV1,
"GET",
`/api/public/scores?dataType=TEXT`,
undefined,
authentication,
);
expect(getAllScore.status).toBe(200);
expect(getAllScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 2,
totalPages: 1,
});
for (const val of getAllScore.body.data) {
expect(val).toMatchObject({
dataType: "TEXT",
name: "text-score-name",
});
expect(val.stringValue).toContain("text-value");
}
});
it("get all scores for trace tag 'prod'", async () => {
const getAllScore = await makeZodVerifiedAPICall(
GetScoresResponseV1,
@@ -696,7 +790,7 @@ describe("/api/public/scores API Endpoint", () => {
expect(getAllScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 1,
totalItems: 2,
totalPages: 1,
});
for (const val of getAllScore.body.data) {
@@ -190,6 +190,51 @@ describe("/api/public/v2/scores API Endpoint", () => {
dataType: "NUMERIC",
});
});
it("should GET a text score", async () => {
const { projectId, auth } = await createOrgProjectAndApiKey();
const scoreId = v4();
const traceId = v4();
const score = createTraceScore({
id: scoreId,
project_id: projectId,
trace_id: traceId,
name: "Text Score",
timestamp: Date.now(),
value: 0,
string_value: "Great explanation",
source: "API",
comment: "comment",
data_type: "TEXT" as const,
created_at: Date.now(),
updated_at: Date.now(),
event_ts: Date.now(),
is_deleted: 0,
});
await createScoresCh([score]);
const getScore = await makeZodVerifiedAPICall(
GetScoreResponseV2,
"GET",
`/api/public/v2/scores/${scoreId}`,
undefined,
auth,
);
expect(getScore.status).toBe(200);
expect(getScore.body).toMatchObject({
id: scoreId,
name: "Text Score",
stringValue: "Great explanation",
comment: "comment",
source: "API",
traceId,
dataType: "TEXT",
});
expect(getScore.body).not.toHaveProperty("value");
});
});
describe("GET /api/public/scores", () => {
@@ -217,6 +262,8 @@ describe("/api/public/v2/scores API Endpoint", () => {
const scoreId_9 = v4();
const correctionScoreId_1 = v4();
const correctionScoreId_2 = v4();
const textScoreId_1 = v4();
const textScoreId_2 = v4();
let authentication: string;
let newProjectId: string;
let executionTraceId: string;
@@ -348,6 +395,26 @@ describe("/api/public/v2/scores API Endpoint", () => {
environment: "annotation",
});
const textScore1 = createTraceScore({
id: textScoreId_1,
project_id: newProjectId,
trace_id: traceId_2,
name: "text-score-name",
data_type: "TEXT",
string_value: "text-value-1",
value: 0,
});
const textScore2 = createTraceScore({
id: textScoreId_2,
project_id: newProjectId,
trace_id: traceId_3,
name: "text-score-name",
data_type: "TEXT",
string_value: "text-value-2",
value: 0,
});
const sessionScore1 = createSessionScore({
id: scoreId_6,
project_id: newProjectId,
@@ -396,6 +463,8 @@ describe("/api/public/v2/scores API Endpoint", () => {
runScore2,
correction1,
correction2,
textScore1,
textScore2,
]);
});
@@ -411,7 +480,7 @@ describe("/api/public/v2/scores API Endpoint", () => {
expect(getAllScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 11,
totalItems: 13,
totalPages: 1,
});
for (const val of getAllScore.body.data) {
@@ -517,6 +586,31 @@ describe("/api/public/v2/scores API Endpoint", () => {
}
});
it("get all scores for text data type", async () => {
const getAllScore = await makeZodVerifiedAPICall(
GetScoresResponseV2,
"GET",
`/api/public/v2/scores?dataType=TEXT`,
undefined,
authentication,
);
expect(getAllScore.status).toBe(200);
expect(getAllScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 2,
totalPages: 1,
});
for (const val of getAllScore.body.data) {
expect(val).toMatchObject({
dataType: "TEXT",
name: "text-score-name",
});
expect(val.stringValue).toContain("text-value");
}
});
it("get all scores for trace tag 'prod'", async () => {
const getAllScore = await makeZodVerifiedAPICall(
GetScoresResponseV2,
@@ -737,7 +831,7 @@ describe("/api/public/v2/scores API Endpoint", () => {
expect(getAllScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 2,
totalItems: 3,
totalPages: 1,
});
for (const val of getAllScore.body.data) {
@@ -220,6 +220,139 @@ describe("/api/public/traces API Endpoint", () => {
);
});
it("should fetch a trace with core-only fields when fields=core", async () => {
const traceId = randomUUID();
const createdTrace = createTrace({
id: traceId,
name: "trace-core-only",
user_id: "user-1",
project_id: projectId,
metadata: { key: "value" },
input: JSON.stringify({ prompt: "test" }),
output: JSON.stringify({ response: "test response" }),
});
const observation = createObservation({
trace_id: traceId,
project_id: projectId,
name: "test-observation",
end_time: new Date().getTime(),
start_time: new Date().getTime() - 1000,
cost_details: { input: 0.02, output: 0.03, total: 0.05 },
});
const score = createTraceScore({
trace_id: traceId,
project_id: projectId,
name: "test-score",
value: 0.8,
});
await createTracesCh([createdTrace]);
await createObservationsCh([observation]);
await createScoresCh([score]);
const trace = await makeZodVerifiedAPICall(
GetTraceV1Response,
"GET",
`/api/public/traces/${traceId}?fields=core`,
);
expect(trace.body.id).toBe(traceId);
expect(trace.body.input).toBeNull();
expect(trace.body.output).toBeNull();
expect(trace.body.metadata).toEqual({});
expect(trace.body.observations).toEqual([]);
expect(trace.body.scores).toEqual([]);
expect(trace.body.totalCost).toBe(-1);
expect(trace.body.latency).toBe(-1);
});
it("should fetch a trace with core,scores,metrics fields", async () => {
const traceId = randomUUID();
const createdTrace = createTrace({
id: traceId,
name: "trace-with-scores-metrics",
project_id: projectId,
input: JSON.stringify({ prompt: "test" }),
output: JSON.stringify({ response: "test response" }),
});
const observation = createObservation({
trace_id: traceId,
project_id: projectId,
name: "test-observation",
end_time: new Date().getTime(),
start_time: new Date().getTime() - 1000,
cost_details: { input: 0.02, output: 0.03, total: 0.05 },
input: "observation input",
output: "observation output",
});
const score = createTraceScore({
trace_id: traceId,
project_id: projectId,
name: "test-score",
value: 0.8,
});
await createTracesCh([createdTrace]);
await createObservationsCh([observation]);
await createScoresCh([score]);
const trace = await makeZodVerifiedAPICall(
GetTraceV1Response,
"GET",
`/api/public/traces/${traceId}?fields=core,scores,metrics`,
);
expect(trace.body.id).toBe(traceId);
expect(trace.body.input).toBeNull();
expect(trace.body.output).toBeNull();
expect(trace.body.observations).toEqual([]);
expect(trace.body.scores).toHaveLength(1);
expect(trace.body.totalCost).toBe(0.05);
expect(trace.body.latency).toBeCloseTo(1);
});
it("should return all fields when fields param contains only invalid groups", async () => {
const traceId = randomUUID();
const createdTrace = createTrace({
id: traceId,
name: "trace-invalid-fields",
project_id: projectId,
input: JSON.stringify({ prompt: "test" }),
output: JSON.stringify({ response: "test response" }),
metadata: { key: "value" },
});
const observation = createObservation({
trace_id: traceId,
project_id: projectId,
name: "test-observation",
end_time: new Date().getTime(),
start_time: new Date().getTime() - 1000,
cost_details: { input: 0.01, output: 0.02, total: 0.03 },
});
await createTracesCh([createdTrace]);
await createObservationsCh([observation]);
const trace = await makeZodVerifiedAPICall(
GetTraceV1Response,
"GET",
`/api/public/traces/${traceId}?fields=invalid_group,also_invalid`,
);
// All invalid fields should fall back to returning all field groups
expect(trace.body.id).toBe(traceId);
expect(trace.body.input).not.toBeNull();
expect(trace.body.output).not.toBeNull();
expect(trace.body.observations).toHaveLength(1);
expect(trace.body.totalCost).toBeGreaterThanOrEqual(0);
expect(trace.body.latency).toBeGreaterThanOrEqual(0);
});
it("should fetch all traces", async () => {
const timestamp = new Date();
const createdTrace = createTrace({
@@ -0,0 +1,133 @@
/** @jest-environment node */
import {
validateConfigAgainstBody,
type ScoreConfigDomain,
type ScoreDomain,
} from "@langfuse/shared/src/server";
const baseTextConfig: ScoreConfigDomain = {
id: "config-1",
name: "free-text-config",
dataType: "TEXT",
isArchived: false,
description: null,
createdAt: new Date(),
updatedAt: new Date(),
projectId: "project-1",
maxValue: undefined,
minValue: undefined,
categories: undefined,
};
const baseTextScore: ScoreDomain = {
id: "score-1",
projectId: "project-1",
name: "free-text-config",
value: 0,
stringValue: "Some valid free text",
dataType: "TEXT",
source: "ANNOTATION",
comment: null,
metadata: {},
configId: "config-1",
queueId: null,
executionTraceId: null,
authorUserId: null,
environment: "default",
createdAt: new Date(),
updatedAt: new Date(),
timestamp: new Date(),
traceId: "trace-1",
sessionId: null,
observationId: null,
datasetRunId: null,
longStringValue: "",
};
describe("validateConfigAgainstBody for TEXT scores", () => {
it("should succeed with valid TEXT annotation score", () => {
expect(() =>
validateConfigAgainstBody({
body: baseTextScore,
config: baseTextConfig,
context: "ANNOTATION",
}),
).not.toThrow();
});
it("should reject empty TEXT text", () => {
const emptyScore: ScoreDomain = {
...baseTextScore,
stringValue: "",
};
expect(() =>
validateConfigAgainstBody({
body: emptyScore,
config: baseTextConfig,
context: "ANNOTATION",
}),
).toThrow();
});
it("should reject TEXT text exceeding 500 characters", () => {
const longScore: ScoreDomain = {
...baseTextScore,
stringValue: "a".repeat(501),
};
expect(() =>
validateConfigAgainstBody({
body: longScore,
config: baseTextConfig,
context: "ANNOTATION",
}),
).toThrow();
});
it("should accept TEXT text at exactly 500 characters", () => {
const maxLengthScore: ScoreDomain = {
...baseTextScore,
stringValue: "a".repeat(500),
};
expect(() =>
validateConfigAgainstBody({
body: maxLengthScore,
config: baseTextConfig,
context: "ANNOTATION",
}),
).not.toThrow();
});
it("should reject archived config", () => {
const archivedConfig: ScoreConfigDomain = {
...baseTextConfig,
isArchived: true,
};
expect(() =>
validateConfigAgainstBody({
body: baseTextScore,
config: archivedConfig,
context: "ANNOTATION",
}),
).toThrow("Config is archived");
});
it("should reject name mismatch", () => {
const mismatchedScore: ScoreDomain = {
...baseTextScore,
name: "different-name",
};
expect(() =>
validateConfigAgainstBody({
body: mismatchedScore,
config: baseTextConfig,
context: "ANNOTATION",
}),
).toThrow("Name mismatch");
});
});
@@ -0,0 +1,126 @@
import { ChatMessageRole, ChatMessageType } from "@langfuse/shared";
import { createMessageSearchController } from "./controller";
describe("message search controller", () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
const commitQuery = (
controller: ReturnType<typeof createMessageSearchController>,
query: string,
) => {
controller.setQueryInput(query);
jest.runAllTimers();
return controller.getSnapshot().matches;
};
it("finds all occurrences of a query", () => {
const controller = createMessageSearchController(["page-1"]);
controller.registerPageMessages("page-1", [
{
id: "message-1",
type: ChatMessageType.System,
role: ChatMessageRole.System,
content:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. ",
},
{
id: "message-2",
type: ChatMessageType.User,
role: ChatMessageRole.User,
content:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. ",
},
]);
expect(commitQuery(controller, "Lorem")).toEqual([
expect.objectContaining({
messageId: "message-1",
from: 0,
to: 5,
}),
expect.objectContaining({
messageId: "message-2",
from: 0,
to: 5,
}),
]);
expect(commitQuery(controller, "dolor")).toEqual([
expect.objectContaining({
messageId: "message-1",
from: 12,
to: 17,
}),
expect.objectContaining({
messageId: "message-1",
from: 103,
to: 108,
}),
expect.objectContaining({
messageId: "message-2",
from: 12,
to: 17,
}),
expect.objectContaining({
messageId: "message-2",
from: 103,
to: 108,
}),
]);
});
// Regression test for https://github.com/langfuse/langfuse/issues/13002
it("matches fullwidth and halfwidth variants consistently", () => {
const controller = createMessageSearchController(["page-1"]);
controller.registerPageMessages("page-1", [
{
id: "message-1",
type: ChatMessageType.System,
role: ChatMessageRole.System,
content:
"Langfuse is an LLM observability platform. Langfuse is also great.",
},
]);
expect(commitQuery(controller, "Langfuse")).toEqual([
expect.objectContaining({ from: 0, to: 8 }),
expect.objectContaining({ from: 43, to: 51 }),
]);
expect(commitQuery(controller, "Langfuse")).toEqual([
expect.objectContaining({ from: 0, to: 8 }),
expect.objectContaining({ from: 43, to: 51 }),
]);
expect(commitQuery(controller, "langfuse")).toEqual([
expect.objectContaining({ from: 0, to: 8 }),
expect.objectContaining({ from: 43, to: 51 }),
]);
});
// Regression test for https://github.com/langfuse/langfuse/issues/13002
it("returns original document offsets for compatibility character matches", () => {
const controller = createMessageSearchController(["page-1"]);
controller.registerPageMessages("page-1", [
{
id: "message-1",
type: ChatMessageType.System,
role: ChatMessageRole.System,
content: "高さ180㌢の棚",
},
]);
expect(commitQuery(controller, "センチ")).toEqual([
expect.objectContaining({ from: 5, to: 6 }),
]);
});
});
@@ -3,11 +3,14 @@
import capitalize from "lodash/capitalize";
import { type ReactCodeMirrorRef } from "@uiw/react-codemirror";
import { ChatMessageType, type ChatMessageWithId } from "@langfuse/shared";
import { EditorState } from "@codemirror/state";
import { SearchQuery } from "@codemirror/search";
import { type RefObject } from "react";
import {
applyCodeMirrorSearchQuery,
selectCodeMirrorRange,
unsetActiveSearchMarkCodeMirrorRange,
setActiveSearchMarkCodeMirrorRange,
} from "@/src/components/editor";
export type MessageSearchMatch = {
@@ -136,7 +139,11 @@ function buildMatches(state: MessageSearchState) {
return [];
}
const lowerQuery = searchQuery.toLocaleLowerCase();
const codeMirrorSearchQuery = new SearchQuery({
search: searchQuery,
caseSensitive: false,
literal: true,
});
const allMatches: MessageSearchMatch[] = [];
for (const [pageIndex, pageId] of state.pageIds.entries()) {
@@ -151,10 +158,13 @@ function buildMatches(state: MessageSearchState) {
continue;
}
const lowerText = text.toLocaleLowerCase();
let from = lowerText.indexOf(lowerQuery);
const cursor = codeMirrorSearchQuery.getCursor(
EditorState.create({ doc: text }),
);
let match = cursor.next();
while (from !== -1) {
while (!match.done) {
const { from, to } = match.value;
const label = getMessageSearchLabel(message, messageIndex);
const pageLabel = state.getPageLabel?.(pageId, pageIndex);
const matchWithoutKey = {
@@ -163,7 +173,7 @@ function buildMatches(state: MessageSearchState) {
label,
locationLabel: pageLabel ? `${pageLabel} · ${label}` : label,
from,
to: from + searchQuery.length,
to,
text,
};
@@ -172,10 +182,7 @@ function buildMatches(state: MessageSearchState) {
...matchWithoutKey,
});
from = lowerText.indexOf(
lowerQuery,
from + Math.max(1, lowerQuery.length),
);
match = cursor.next();
}
}
}
@@ -269,17 +276,33 @@ export function createMessageSearchController(
inline: "center",
});
const messageTarget = messageTargets.get(
getMessageTargetKey(activeMatch.pageId, activeMatch.messageId),
let activeMessageTarget: MessageSearchMessageTarget | null = null;
const inactiveMessageTargets: MessageSearchMessageTarget[] = [];
const activeMessageTargetKey = getMessageTargetKey(
activeMatch.pageId,
activeMatch.messageId,
);
messageTarget?.rowRef.current?.scrollIntoView({
for (const [key, target] of messageTargets.entries()) {
if (key === activeMessageTargetKey) {
activeMessageTarget = target;
} else {
inactiveMessageTargets.push(target);
}
}
for (const target of inactiveMessageTargets) {
unsetActiveSearchMarkCodeMirrorRange(target?.editorRef);
}
activeMessageTarget?.rowRef.current?.scrollIntoView({
behavior: "smooth",
block: "center",
inline: "nearest",
});
selectCodeMirrorRange(messageTarget?.editorRef, {
setActiveSearchMarkCodeMirrorRange(activeMessageTarget?.editorRef, {
from: activeMatch.from,
to: activeMatch.to,
});
@@ -318,7 +341,7 @@ export function createMessageSearchController(
const refreshSearchResults = (shouldSyncEditors: boolean) => {
const activeMatchChanged = recomputeMatches();
if (shouldSyncEditors) {
if (shouldSyncEditors || activeMatchChanged) {
syncEditorsToQuery();
}
@@ -21,7 +21,6 @@ export function MessageSearchToolbar({ className }: { className?: string }) {
openRequestCount,
queryInput,
matches,
activeMatch,
activeMatchIndex,
openSearch,
closeSearch,
@@ -109,9 +108,6 @@ export function MessageSearchToolbar({ className }: { className?: string }) {
onClick={nextMatch}
disabled={matches.length === 0}
/>
<div className="text-muted-foreground hidden max-w-48 truncate px-1 text-xs lg:block">
{activeMatch?.locationLabel ?? "No matches"}
</div>
<IconButton icon={X} label="Close search" onClick={closeSearch} />
</div>
);
+185 -14
View File
@@ -6,7 +6,7 @@ import CodeMirror, {
ViewPlugin,
type ViewUpdate,
} from "@uiw/react-codemirror";
import { RangeSetBuilder } from "@codemirror/state";
import { RangeSetBuilder, StateEffect, StateField } from "@codemirror/state";
import { SearchQuery, search, setSearchQuery } from "@codemirror/search";
import { json, jsonParseLinter } from "@codemirror/lang-json";
import { linter, type Diagnostic } from "@codemirror/lint";
@@ -165,6 +165,142 @@ const bidiSupport = [
),
];
// Add custom search highlight decoration using the same class names as the default search match decorations
// See: https://github.com/codemirror/search/blob/36e8f21e070d471fcbe2e2f338ef4e647b492ba8/src/search.ts#L396
const searchMatchMark = Decoration.mark({
class: "cm-searchMatch",
});
const selectedSearchMatchMark = Decoration.mark({
class: "cm-searchMatch cm-searchMatch-selected",
});
const setSearchHighlightMarks = StateEffect.define<
{
from: number;
to: number;
}[]
>({
map: (ranges, change) =>
ranges.map(({ from, to }) => ({
from: change.mapPos(from),
to: change.mapPos(to),
})),
});
const setSelectedSearchHighlightMark = StateEffect.define<{
from: number;
to: number;
}>({
map: ({ from, to }, change) => ({
from: change.mapPos(from),
to: change.mapPos(to),
}),
});
const unsetSelectedSearchHighlightMark = StateEffect.define({});
const searchHighlightingSupport = StateField.define<DecorationSet>({
create() {
return Decoration.none;
},
update(decos, tr) {
decos = decos.map(tr.changes);
for (const effect of tr.effects) {
if (effect.is(setSearchHighlightMarks)) {
// Remove all existing search highlights
decos = decos.update({
filter: (from, to, decoration) => {
return !decoration.spec.class?.includes("cm-searchMatch");
},
});
decos = decos.update({
add: effect.value.map(({ from, to }) =>
searchMatchMark.range(from, to),
),
});
}
if (effect.is(unsetSelectedSearchHighlightMark)) {
let selectedRange: { from: number; to: number } | null = null;
decos = decos.update({
filter: (from, to, decoration) => {
if (decoration.spec.class?.includes("cm-searchMatch-selected")) {
selectedRange = { from, to };
return false;
}
return true;
},
});
// Reassign the value and cast it because typescript infers it to be always null,
// not recognizing it to be assigned in the filter above.
selectedRange = selectedRange as {
from: number;
to: number;
} | null;
if (selectedRange) {
decos = decos.update({
add: [searchMatchMark.range(selectedRange.from, selectedRange.to)],
});
}
}
if (effect.is(setSelectedSearchHighlightMark)) {
// Remove normal search match mark from the selected range,
// otherwise there will _both_ a normal and selected highlight on the active match.
decos = decos.update({
filter: (from, to, decoration) => {
if (from === effect.value.from && to === effect.value.to) {
return !decoration.spec.class?.includes("cm-searchMatch");
}
return true;
},
});
let previousSelectedRange: { from: number; to: number } | null = null;
// Make the existing selected search highlight a normal search highlight
decos = decos.update({
filter: (from, to, decoration) => {
if (decoration.spec.class?.includes("cm-searchMatch-selected")) {
previousSelectedRange = { from, to };
return false;
}
return true;
},
});
// Reassign the value and cast it because typescript infers it to be always null,
// not recognizing it to be assigned in the filter above.
previousSelectedRange = previousSelectedRange as {
from: number;
to: number;
} | null;
decos = decos.update({
add: [
...(previousSelectedRange
? [
searchMatchMark.range(
previousSelectedRange.from,
previousSelectedRange.to,
),
]
: []),
selectedSearchMatchMark.range(effect.value.from, effect.value.to),
].toSorted((a, b) => a.from - b.from),
});
}
}
return decos;
},
provide: (f) => EditorView.decorations.from(f),
});
export function applyCodeMirrorSearchQuery(
editorRef: RefObject<ReactCodeMirrorRef | null> | undefined,
searchValue: string,
@@ -174,18 +310,30 @@ export function applyCodeMirrorSearchQuery(
return;
}
const searchQuery = new SearchQuery({
search: searchValue,
caseSensitive: false,
literal: true,
});
view.dispatch({
effects: setSearchQuery.of(
new SearchQuery({
search: searchValue,
caseSensitive: false,
literal: true,
}),
),
effects: setSearchQuery.of(searchQuery),
});
const cursor = searchQuery.getCursor(view.state);
const matchRanges: { from: number; to: number }[] = [];
let current = cursor.next();
while (!current.done) {
matchRanges.push(current.value);
current = cursor.next();
}
view.dispatch({
effects: setSearchHighlightMarks.of(matchRanges),
});
}
export function selectCodeMirrorRange(
export function setActiveSearchMarkCodeMirrorRange(
editorRef: RefObject<ReactCodeMirrorRef | null> | undefined,
range: { from: number; to: number } | null,
) {
@@ -195,11 +343,23 @@ export function selectCodeMirrorRange(
}
view.dispatch({
selection: {
anchor: range.from,
head: range.to,
},
scrollIntoView: true,
effects: [
setSelectedSearchHighlightMark.of(range),
EditorView.scrollIntoView(range.from),
],
});
}
export function unsetActiveSearchMarkCodeMirrorRange(
editorRef: RefObject<ReactCodeMirrorRef | null> | undefined,
) {
const view = editorRef?.current?.view;
if (!view) {
return;
}
view.dispatch({
effects: unsetSelectedSearchHighlightMark.of(null),
});
}
@@ -268,6 +428,7 @@ export function CodeMirrorEditor({
}}
lang={mode === "json" ? "json" : undefined}
extensions={[
searchHighlightingSupport,
search(),
// RTL/bidi support - must be early for proper line decoration
...bidiSupport,
@@ -277,6 +438,16 @@ export function CodeMirrorEditor({
outline: "none",
},
}),
// Update search match highlight styles
EditorView.theme({
".cm-searchMatch.cm-searchMatch": {
backgroundColor: "hsl(var(--find-match-background))",
},
".cm-searchMatch.cm-searchMatch-selected": {
backgroundColor: "hsl(var(--find-match-selected-background))",
color: "hsl(var(--find-match-selected-foreground))",
},
}),
// Hide gutter when lineNumbers is false
// Fix missing gutter border
...(!lineNumbers
+9 -7
View File
@@ -65,24 +65,26 @@ const ScoreGroupBadge = <
<Badge
variant="tertiary"
key={name}
className={`flex items-center gap-1 ${compact ? "px-1.5 leading-tight" : "px-2.5"} text-xs font-normal${badgeClassName ? " " + badgeClassName : ""}`}
className={`flex max-w-full min-w-0 items-center gap-1 ${compact ? "px-1.5 leading-tight" : "px-2.5"} text-xs font-normal${badgeClassName ? " " + badgeClassName : ""}`}
>
<div
className={`w-fit max-w-20 truncate ${compact ? "leading-tight" : ""}`}
className={`w-fit max-w-20 shrink-0 truncate ${compact ? "leading-tight" : ""}`}
title={name}
>
{name}:
</div>
<div className="flex items-center gap-1 text-nowrap">
<div className="flex min-w-0 items-center gap-1 text-nowrap">
{scores.map((s, i) => (
<span
key={i}
className="group/score ml-1 flex items-center gap-1 rounded-sm first:ml-0"
className="group/score ml-1 flex min-w-0 items-center gap-1 rounded-sm first:ml-0"
>
{s.stringValue ?? s.value?.toFixed(2) ?? ""}
<span className="truncate">
{s.stringValue ?? s.value?.toFixed(2) ?? ""}
</span>
{s.comment && (
<HoverCard>
<HoverCardTrigger className="inline-block">
<HoverCardTrigger className="inline-block shrink-0">
<MessageCircleMoreIcon className="mb-0.25 size-3!" />
</HoverCardTrigger>
<HoverCardContent className="max-h-[50dvh] overflow-y-auto text-xs break-normal whitespace-normal">
@@ -104,7 +106,7 @@ const ScoreGroupBadge = <
)}
{hasMetadata(s) && (
<HoverCard>
<HoverCardTrigger className="inline-block">
<HoverCardTrigger className="inline-block shrink-0">
<BracesIcon className="mb-0.25 size-3!" />
</HoverCardTrigger>
<HoverCardContent className="max-h-[50dvh] overflow-y-auto rounded-md border-none p-0 text-xs break-normal whitespace-normal">
@@ -11,6 +11,7 @@ interface ResizableDesktopLayoutProps {
mainContent: ReactNode;
sidebarContent: ReactNode;
open: boolean;
showHandle?: boolean;
defaultMainSize?: number;
defaultSidebarSize?: number;
minMainSize?: number;
@@ -36,6 +37,7 @@ export function ResizableDesktopLayout({
mainContent,
sidebarContent,
open,
showHandle = true,
defaultMainSize = 70,
defaultSidebarSize = 30,
minMainSize = 30,
@@ -108,7 +110,9 @@ export function ResizableDesktopLayout({
{sidebarContent}
</ResizablePanel>
)}
{sidebarPosition === "left" && open && <ResizableHandle withHandle />}
{sidebarPosition === "left" && open && showHandle && (
<ResizableHandle withHandle />
)}
<ResizablePanel
id={MAIN_PANEL_ID}
defaultSize={`${defaultMainSize}%`}
@@ -121,7 +125,9 @@ export function ResizableDesktopLayout({
{mainContent}
</div>
</ResizablePanel>
{sidebarPosition === "right" && open && <ResizableHandle withHandle />}
{sidebarPosition === "right" && open && showHandle && (
<ResizableHandle withHandle />
)}
{sidebarPosition === "right" && (
<ResizablePanel
id={SIDEBAR_PANEL_ID}
@@ -59,6 +59,7 @@ export function ResizableContent({ children }: PropsWithChildren) {
mainContent={children}
sidebarContent={<SupportDrawer />}
open={open}
showHandle={false}
defaultMainSize={70}
defaultSidebarSize={30}
minMainSize={30}
+6 -3
View File
@@ -74,12 +74,15 @@ export const ScoresTableCell = ({
return (
<span
className={cn("flex flex-row gap-0.5 rounded-sm", COLOR_MAP.get(value))}
className={cn(
"flex min-w-0 flex-row gap-0.5 rounded-sm",
COLOR_MAP.get(value),
)}
>
{value}
<span className="truncate">{value}</span>
{aggregate.comment && (
<HoverCard>
<HoverCardTrigger className="inline-block cursor-pointer">
<HoverCardTrigger className="inline-block shrink-0 cursor-pointer">
<MessageCircleMore size={12} />
</HoverCardTrigger>
<HoverCardContent className="flex flex-col p-0 text-xs break-normal whitespace-normal">
@@ -29,6 +29,7 @@ import {
isPresent,
type FilterState,
type ScoreDataTypeType,
LISTABLE_SCORE_TYPES,
BatchExportTableName,
BatchActionType,
TableViewPresetTableName,
@@ -263,7 +264,7 @@ export default function ScoresTable({
count: n.count !== undefined ? Number(n.count) : undefined,
})) ?? undefined,
source: ["ANNOTATION", "API", "EVAL"],
dataType: ["NUMERIC", "CATEGORICAL", "BOOLEAN"],
dataType: [...LISTABLE_SCORE_TYPES],
value: [],
stringValue:
filterOptions.data?.stringValue?.map((sv) => ({
@@ -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>
+30 -12
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { Button } from "@/src/components/ui/button";
import {
Check,
@@ -217,10 +217,18 @@ export function CodeView(props: {
defaultCollapsed?: boolean;
title?: string;
scrollable?: boolean;
copiedToClipboardMessage?: string;
}) {
const { copiedToClipboardMessage } = props;
const [isCopied, setIsCopied] = useState(false);
const [isCollapsed, setCollapsed] = useState(props.defaultCollapsed);
const copySuccessStateDuration = copiedToClipboardMessage ? 3_000 : 1_000;
const copySuccessTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const handleCopy = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
setIsCopied(true);
@@ -230,7 +238,12 @@ export function CodeView(props: {
? props.content
: (props.content?.join("\n") ?? ""));
void copyTextToClipboard(content);
setTimeout(() => setIsCopied(false), 1000);
if (copySuccessTimeoutRef.current)
clearTimeout(copySuccessTimeoutRef.current);
copySuccessTimeoutRef.current = setTimeout(
() => setIsCopied(false),
copySuccessStateDuration,
);
// Keep focus on the copy button to prevent focus shifting
event.currentTarget.focus();
@@ -238,6 +251,19 @@ export function CodeView(props: {
const handleShowAll = () => setCollapsed(!isCollapsed);
const CopySuccessIcon = useMemo(() => {
return (
<div className="animate-appear relative h-3">
<Check className="h-3 w-3" />
{copiedToClipboardMessage && (
<div className="text-secondary-foreground absolute top-0 right-0 mr-6 h-full max-w-[60vw] transform truncate overflow-hidden text-right text-sm leading-none whitespace-nowrap">
{copiedToClipboardMessage}
</div>
)}
</div>
);
}, [copiedToClipboardMessage]);
return (
<div
className={cn(
@@ -256,11 +282,7 @@ export function CodeView(props: {
onClick={handleCopy}
className=""
>
{isCopied ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
{isCopied ? CopySuccessIcon : <Copy className="h-3 w-3" />}
</Button>
</div>
) : undefined}
@@ -278,11 +300,7 @@ export function CodeView(props: {
onClick={handleCopy}
className="absolute top-2 right-2 z-10"
>
{isCopied ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
{isCopied ? CopySuccessIcon : <Copy className="h-3 w-3" />}
</Button>
)}
<code
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.164.0";
export const VERSION = "v3.167.0";
+3
View File
@@ -399,6 +399,7 @@ export const env = createEnv({
.enum(["true", "false"])
.default("false"),
LANGFUSE_API_TRACES_DEFAULT_FIELDS: z.string().optional(),
LANGFUSE_API_TRACEBYID_DEFAULT_FIELDS: z.string().optional(),
// Events table migration
LANGFUSE_ENABLE_EVENTS_TABLE_OBSERVATIONS: z
@@ -781,6 +782,8 @@ export const env = createEnv({
process.env.LANGFUSE_API_TRACES_REJECT_NO_DATE_RANGE,
LANGFUSE_API_TRACES_DEFAULT_FIELDS:
process.env.LANGFUSE_API_TRACES_DEFAULT_FIELDS,
LANGFUSE_API_TRACEBYID_DEFAULT_FIELDS:
process.env.LANGFUSE_API_TRACEBYID_DEFAULT_FIELDS,
// Events table migration
LANGFUSE_ENABLE_EVENTS_TABLE_OBSERVATIONS:
process.env.LANGFUSE_ENABLE_EVENTS_TABLE_OBSERVATIONS,
@@ -16,6 +16,8 @@ import { useAnnotationObjectData } from "./shared/hooks/useAnnotationObjectData"
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;
@@ -24,6 +26,9 @@ 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<
RouterOutput["annotationQueues"]["fetchAndLockNext"] | null
@@ -39,8 +44,8 @@ export const AnnotationQueueItemPage: React.FC<{
const itemId = isSingleItem ? queryItemId : seenItemIds[progressIndex];
const seenItemData = api.annotationQueueItems.byId.useQuery(
{ projectId, itemId: itemId as string },
{ enabled: !!itemId, refetchOnMount: false },
{ projectId, itemId: itemId as string, isBetaEnabled },
{ enabled: !!itemId && sessionLoaded, refetchOnMount: false },
);
const fetchAndLockNextMutation =
@@ -49,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 =
@@ -86,6 +92,7 @@ export const AnnotationQueueItemPage: React.FC<{
queueId: annotationQueueId,
projectId,
seenItemIds,
isBetaEnabled,
});
setNextItemData(nextItem);
}
@@ -142,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" />;
}
@@ -163,6 +171,7 @@ export const AnnotationQueueItemPage: React.FC<{
queueId: annotationQueueId,
projectId,
seenItemIds,
isBetaEnabled,
});
setNextItemData(nextItem);
}
@@ -3,6 +3,8 @@ import {
type AnnotationQueueItem,
AnnotationQueueObjectType,
} from "@langfuse/shared";
import { useV4Beta } from "@/src/features/events/hooks/useV4Beta";
import { useEventsTraceData } from "@/src/features/events/hooks/useEventsTraceData";
export interface ObjectDataHook<TData> {
data: TData | undefined;
@@ -15,15 +17,19 @@ export const useAnnotationObjectData = (
item: (AnnotationQueueItem & { parentTraceId?: string | null }) | null,
projectId: string,
): ObjectDataHook<any> => {
const { isBetaEnabled } = useV4Beta();
const traceId = item?.parentTraceId ?? item?.objectId;
const isTraceOrObservation =
!!item &&
(item.objectType === AnnotationQueueObjectType.TRACE ||
item.objectType === AnnotationQueueObjectType.OBSERVATION);
// Old path: fetch from traces table (beta OFF)
const traceQuery = api.traces.byIdWithObservationsAndScores.useQuery(
{ traceId: traceId as string, projectId },
{
enabled:
!!item &&
(item.objectType === AnnotationQueueObjectType.TRACE ||
item.objectType === AnnotationQueueObjectType.OBSERVATION),
enabled: isTraceOrObservation && !isBetaEnabled,
retry(failureCount, error) {
if (
error.data?.code === "UNAUTHORIZED" ||
@@ -35,13 +41,42 @@ export const useAnnotationObjectData = (
},
);
const eventsData = useEventsTraceData({
projectId,
traceId: traceId ?? "",
enabled: isTraceOrObservation && isBetaEnabled,
});
const isSession =
!!item && item.objectType === AnnotationQueueObjectType.SESSION;
// Old path: fetch from traces table (beta OFF)
const sessionQuery = api.sessions.byIdWithScores.useQuery(
{
sessionId: item?.objectId as string,
projectId,
},
{
enabled: !!item && item.objectType === AnnotationQueueObjectType.SESSION,
enabled: isSession && !isBetaEnabled,
retry(failureCount, error) {
if (
error.data?.code === "UNAUTHORIZED" ||
error.data?.code === "NOT_FOUND"
)
return false;
return failureCount < 3;
},
},
);
// New path: fetch from events table (beta ON)
const sessionEventsQuery = api.sessions.byIdWithScoresFromEvents.useQuery(
{
sessionId: item?.objectId as string,
projectId,
},
{
enabled: isSession && isBetaEnabled,
retry(failureCount, error) {
if (
error.data?.code === "UNAUTHORIZED" ||
@@ -64,6 +99,14 @@ export const useAnnotationObjectData = (
switch (item.objectType) {
case AnnotationQueueObjectType.TRACE:
case AnnotationQueueObjectType.OBSERVATION:
if (isBetaEnabled) {
return {
data: eventsData.data,
isLoading: eventsData.isLoading,
isError: !!eventsData.error,
errorCode: (eventsData.error as any)?.data?.code,
};
}
return {
data: traceQuery.data,
isLoading: traceQuery.isLoading,
@@ -71,6 +114,14 @@ export const useAnnotationObjectData = (
errorCode: traceQuery.error?.data?.code,
};
case AnnotationQueueObjectType.SESSION:
if (isBetaEnabled) {
return {
data: sessionEventsQuery.data,
isLoading: sessionEventsQuery.isLoading,
isError: sessionEventsQuery.isError,
errorCode: sessionEventsQuery.error?.data?.code,
};
}
return {
data: sessionQuery.data,
isLoading: sessionQuery.isLoading,
@@ -20,6 +20,7 @@ import {
} from "@langfuse/shared";
import {
getObservationById,
getObservationByIdFromEventsTable,
getTraceIdsForObservations,
logger,
} from "@langfuse/shared/src/server";
@@ -85,6 +86,7 @@ export const queueItemRouter = createTRPCRouter({
z.object({
projectId: z.string(),
itemId: z.string(),
isBetaEnabled: z.boolean().optional().default(false),
}),
)
.query(async ({ input, ctx }) => {
@@ -122,10 +124,15 @@ export const queueItemRouter = createTRPCRouter({
};
if (item.objectType === AnnotationQueueObjectType.OBSERVATION) {
const clickhouseObservation = await getObservationById({
id: item.objectId,
projectId: input.projectId,
});
const clickhouseObservation = input.isBetaEnabled
? await getObservationByIdFromEventsTable({
id: item.objectId,
projectId: input.projectId,
})
: await getObservationById({
id: item.objectId,
projectId: input.projectId,
});
if (!clickhouseObservation) {
throw new LangfuseNotFoundError("Observation not found");
@@ -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(
@@ -51,6 +51,7 @@ import { AlertCircle, ExternalLink } from "lucide-react";
import { useVariableMappingSync } from "@/src/features/evals/hooks/useVariableMappingSync";
import { Button } from "@/src/components/ui/button";
import Link from "next/link";
import { useRouter } from "next/router";
export const VariableMappingCard = ({
projectId,
@@ -74,6 +75,14 @@ export const VariableMappingCard = ({
hideAdvancedSettings?: boolean;
}) => {
const [showPreview, setShowPreview] = useState(false);
const [selectedPreviewIds, setSelectedPreviewIds] = useState<{
traceId?: string;
observationId?: string;
}>();
const router = useRouter();
const peekId =
typeof router.query.peek === "string" ? router.query.peek : undefined;
const isPeekView = Boolean(peekId);
const { fields } = useFieldArray({
control: form.control,
@@ -89,6 +98,7 @@ export const VariableMappingCard = ({
projectId,
form,
disabled,
isPeekView ? (selectedPreviewIds ?? {}) : undefined,
);
useEffect(() => {
@@ -98,8 +108,18 @@ export const VariableMappingCard = ({
// For dataset and experiment targets, disable preview
setShowPreview(false);
}
if (isPeekView) {
setSelectedPreviewIds(undefined);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [form.watch("target"), disabled]);
}, [form.watch("target"), disabled, isPeekView]);
useEffect(() => {
if (isPeekView) {
setSelectedPreviewIds(undefined);
}
}, [isPeekView, peekId]);
const mappingControlButtons = (
<div className="flex items-center gap-2">
@@ -124,6 +144,23 @@ export const VariableMappingCard = ({
? "observations"
: "traces"
}
onNavigate={
isPeekView
? (entry) => {
if (isEventTarget(form.watch("target"))) {
setSelectedPreviewIds({
traceId: entry.params?.traceId,
observationId: entry.id,
});
} else {
setSelectedPreviewIds({
traceId: entry.id,
observationId: undefined,
});
}
}
: undefined
}
path={(entry) => {
const isEvent = isEventTarget(form.watch("target"));
const basePath = hideAdvancedSettings
@@ -173,7 +210,7 @@ export const VariableMappingCard = ({
title="Only user-managed templates can be edited"
>
Edit prompt
<ExternalLink className="h-4 w-4" />
<ExternalLink className="ml-1 h-4 w-4" />
</Button>
)}
</div>
@@ -18,16 +18,24 @@ export function useEvalConfigMappingData(
projectId: string,
form: UseFormReturn<EvalFormType>,
disabled = false,
selectedPreviewIds?: {
traceId?: string;
observationId?: string;
},
): EvalConfigMappingData {
const router = useRouter();
// Get traceId and observationId from URL query parameters
// In full-page mode, keep using URL params. In peek mode, callers can manage preview ids locally.
const traceId =
typeof router.query.traceId === "string" ? router.query.traceId : undefined;
selectedPreviewIds?.traceId ??
(typeof router.query.traceId === "string"
? router.query.traceId
: undefined);
const observationId =
typeof router.query.observationId === "string"
selectedPreviewIds?.observationId ??
(typeof router.query.observationId === "string"
? router.query.observationId
: undefined;
: undefined);
const { previewData, isLoading } = usePreviewData(
projectId,
@@ -39,9 +47,15 @@ export function useEvalConfigMappingData(
const targetValue = form.watch("target");
const prevTargetRef = useRef(targetValue);
const isLocallyManagedPreview = Boolean(selectedPreviewIds);
// drop the traceId and observation-related params from the URL query parameters when target changes
useEffect(() => {
if (isLocallyManagedPreview) {
prevTargetRef.current = targetValue;
return;
}
if (
prevTargetRef.current !== targetValue &&
prevTargetRef.current !== undefined
@@ -60,7 +74,7 @@ export function useEvalConfigMappingData(
);
}
prevTargetRef.current = targetValue;
}, [targetValue, router]);
}, [targetValue, router, isLocallyManagedPreview]);
const observationTypeToNames = new Map<ObservationType, Set<string>>([
["SPAN", new Set()],
@@ -277,7 +277,7 @@ export default function ObservationsEventsTable({
const [rawRefreshInterval, setRawRefreshInterval] =
useSessionStorage<RefreshInterval>(
`tableRefreshInterval-events-${projectId}`,
60_000,
null,
);
// Validate session storage value against allowed intervals
@@ -6,7 +6,7 @@ import {
} from "@/src/features/events/lib/eventsToTraceAdapter";
import {
filterAndValidateDbScoreList,
AGGREGATABLE_SCORE_TYPES,
ScoreDataTypeArray,
ScoreDataTypeEnum,
type ScoreDomain,
} from "@langfuse/shared";
@@ -123,7 +123,7 @@ export function useEventsTraceData(
// Validate and partition scores
const validatedScores = filterAndValidateDbScoreList({
scores: scoresQuery.data ?? [],
dataTypes: [...AGGREGATABLE_SCORE_TYPES, ScoreDataTypeEnum.CORRECTION],
dataTypes: [...ScoreDataTypeArray],
onParseError: (e) => {
console.error("[useEventsTraceData] Score validation error:", e);
},
@@ -1,7 +1,7 @@
import { type z } from "zod";
import {
AGGREGATABLE_SCORE_TYPES,
type FilterCondition,
LISTABLE_SCORE_TYPES,
filterAndValidateDbScoreList,
} from "@langfuse/shared";
import {
@@ -128,13 +128,13 @@ export async function getEventList(params: GetObservationsListParams) {
const validatedScores = filterAndValidateDbScoreList({
scores,
dataTypes: AGGREGATABLE_SCORE_TYPES,
dataTypes: LISTABLE_SCORE_TYPES,
includeHasMetadata: true,
onParseError: traceException,
});
const validatedTraceScores = filterAndValidateDbScoreList({
scores: traceScores,
dataTypes: AGGREGATABLE_SCORE_TYPES,
dataTypes: LISTABLE_SCORE_TYPES,
includeHasMetadata: true,
onParseError: traceException,
});
@@ -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>
);
},
},
{
+37 -19
View File
@@ -399,7 +399,13 @@ export const llmApiKeyRouter = createTRPCRouter({
test: protectedProjectProcedureWithoutTracing
.input(CreateLlmApiKey)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
throwIfNoProjectAccess({
session: ctx.session,
projectId: input.projectId,
scope: "llmApiKeys:create",
});
return testLLMConnection({
adapter: input.adapter,
provider: input.provider,
@@ -414,13 +420,13 @@ export const llmApiKeyRouter = createTRPCRouter({
testUpdate: protectedProjectProcedureWithoutTracing
.input(UpdateLlmApiKey)
.mutation(async ({ input, ctx }) => {
try {
throwIfNoProjectAccess({
session: ctx.session,
projectId: input.projectId,
scope: "llmApiKeys:read",
});
throwIfNoProjectAccess({
session: ctx.session,
projectId: input.projectId,
scope: "llmApiKeys:update",
});
try {
// Get the existing key from the database
const existingKey = await ctx.prisma.llmApiKeys.findUnique({
where: {
@@ -436,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,
@@ -12,26 +12,43 @@ import {
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { ChevronDown, ChevronUp } from "lucide-react";
import { useRouter } from "next/router";
import { useEffect } from "react";
import { useCallback, useEffect } from "react";
export const DetailPageNav = (props: {
currentId: string;
path: (entry: ListEntry) => string;
listKey: string;
onNavigate?: (entry: ListEntry) => void;
}) => {
const { currentId, path, listKey, onNavigate } = props;
const { detailPagelists } = useDetailPageLists();
const entries = detailPagelists[props.listKey] ?? [];
const entries = detailPagelists[listKey] ?? [];
const capture = usePostHogClientCapture();
const router = useRouter();
const currentIndex = entries.findIndex(
(entry) => entry.id === props.currentId,
);
const currentIndex = entries.findIndex((entry) => entry.id === currentId);
const previousPageEntry =
currentIndex > 0 ? entries[currentIndex - 1] : undefined;
const nextPageEntry =
currentIndex < entries.length - 1 ? entries[currentIndex + 1] : undefined;
const navigateToEntry = useCallback(
(entry: ListEntry) => {
if (onNavigate) {
onNavigate(entry);
return;
}
void router.push(
path({
id: encodeURIComponent(entry.id),
params: entry.params,
}),
);
},
[onNavigate, path, router],
);
// keyboard shortcuts for buttons k and j
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
@@ -50,22 +67,14 @@ export const DetailPageNav = (props: {
}
if (event.key === "k" && previousPageEntry) {
const newPath = props.path({
id: encodeURIComponent(previousPageEntry.id),
params: previousPageEntry.params,
});
void router.push(newPath);
navigateToEntry(previousPageEntry);
} else if (event.key === "j" && nextPageEntry) {
const newPath = props.path({
id: encodeURIComponent(nextPageEntry.id),
params: nextPageEntry.params,
});
void router.push(newPath);
navigateToEntry(nextPageEntry);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [previousPageEntry, nextPageEntry, router, props]);
}, [previousPageEntry, nextPageEntry, navigateToEntry]);
if (entries.length > 1)
return (
@@ -80,12 +89,7 @@ export const DetailPageNav = (props: {
onClick={() => {
if (previousPageEntry) {
capture("navigate_detail_pages:button_click_prev_or_next");
void router.push(
props.path({
id: encodeURIComponent(previousPageEntry.id),
params: previousPageEntry.params,
}),
);
navigateToEntry(previousPageEntry);
}
}}
>
@@ -113,12 +117,7 @@ export const DetailPageNav = (props: {
onClick={() => {
if (nextPageEntry) {
capture("navigate_detail_pages:button_click_prev_or_next");
void router.push(
props.path({
id: encodeURIComponent(nextPageEntry.id),
params: nextPageEntry.params,
}),
);
navigateToEntry(nextPageEntry);
}
}}
>
@@ -95,7 +95,11 @@ export function ApiKeyList(props: { entityId: string; scope: ApiKeyScope }) {
}}
actionButtons={<CreateApiKeyButton entityId={entityId} scope={scope} />}
/>
<CodeView content={envCode} title=".env" />
<CodeView
content={envCode}
title=".env"
copiedToClipboardMessage="Secrets are not included, create a new key to copy them."
/>
<Card className="mb-4 overflow-hidden">
<Table>
<TableHeader>
@@ -4,10 +4,7 @@ import {
convertScoreToPublicApi,
type ScoreQueryType,
} from "@/src/features/public-api/server/scores";
import {
AGGREGATABLE_SCORE_TYPES,
type ScoreSourceType,
} from "@langfuse/shared";
import { LISTABLE_SCORE_TYPES, type ScoreSourceType } from "@langfuse/shared";
import { _handleGetScoreById } from "@langfuse/shared/src/server";
export class ScoresApiService {
@@ -15,8 +12,8 @@ export class ScoresApiService {
/**
* Get a specific score by ID
* v1: Only returns aggregatable scores (NUMERIC, BOOLEAN, CATEGORICAL) - excludes CORRECTION
* v2: Returns all score types including CORRECTION
* v1: Returns listable scores (NUMERIC, BOOLEAN, CATEGORICAL, TEXT) - excludes CORRECTION
* v2: Returns all score types including CORRECTION and TEXT
*/
async getScoreById({
projectId,
@@ -33,7 +30,7 @@ export class ScoresApiService {
source,
scoreScope: this.apiVersion === "v1" ? "traces_only" : "all",
scoreDataTypes:
this.apiVersion === "v1" ? AGGREGATABLE_SCORE_TYPES : undefined,
this.apiVersion === "v1" ? LISTABLE_SCORE_TYPES : undefined,
preferredClickhouseService: "ReadOnly",
});
@@ -46,29 +43,29 @@ export class ScoresApiService {
/**
* Get list of scores with version-aware filtering
* v1: Only returns aggregatable scores (NUMERIC, BOOLEAN, CATEGORICAL) - excludes CORRECTION
* v2: Returns all score types including CORRECTION
* v1: Returns listable scores (NUMERIC, BOOLEAN, CATEGORICAL, TEXT) - excludes CORRECTION
* v2: Returns all score types including CORRECTION and TEXT
*/
async generateScoresForPublicApi(props: ScoreQueryType) {
return _handleGenerateScoresForPublicApi({
props,
scoreScope: this.apiVersion === "v1" ? "traces_only" : "all",
scoreDataTypes:
this.apiVersion === "v1" ? AGGREGATABLE_SCORE_TYPES : undefined,
this.apiVersion === "v1" ? LISTABLE_SCORE_TYPES : undefined,
});
}
/**
* Get count of scores with version-aware filtering
* v1: Only counts aggregatable scores (NUMERIC, BOOLEAN, CATEGORICAL) - excludes CORRECTION
* v2: Counts all score types including CORRECTION
* v1: Only counts listable scores (NUMERIC, BOOLEAN, CATEGORICAL, TEXT) - excludes CORRECTION
* v2: Counts all score types including CORRECTION and TEXT
*/
async getScoresCountForPublicApi(props: ScoreQueryType) {
return _handleGetScoresCountForPublicApi({
props,
scoreScope: this.apiVersion === "v1" ? "traces_only" : "all",
scoreDataTypes:
this.apiVersion === "v1" ? AGGREGATABLE_SCORE_TYPES : undefined,
this.apiVersion === "v1" ? LISTABLE_SCORE_TYPES : undefined,
});
}
}
+20 -4
View File
@@ -18,14 +18,26 @@ import {
type FilterState,
} from "@langfuse/shared";
type ScoreApiResult = Omit<ScoreDomain, "longStringValue"> & {
stringValue?: string | null;
};
type TextScoreApiResult = Omit<ScoreDomain, "longStringValue" | "value"> & {
stringValue?: string | null;
};
/**
* Converts a ScoreDomain object to API format.
* For CORRECTION scores, moves longStringValue to stringValue for API compatibility.
* For TEXT scores, removes longStringValue and value (always 0, not meaningful).
* For other score types, removes longStringValue.
*/
export const convertScoreToPublicApi = <T extends ScoreDomain>(
score: T,
): Omit<T, "longStringValue"> & { stringValue?: string | null } => {
export function convertScoreToPublicApi(
score: ScoreDomain & { dataType: "TEXT" },
): TextScoreApiResult;
export function convertScoreToPublicApi(score: ScoreDomain): ScoreApiResult;
export function convertScoreToPublicApi(
score: ScoreDomain,
): ScoreApiResult | TextScoreApiResult {
if (score.dataType === ScoreDataTypeEnum.CORRECTION) {
const { longStringValue, ...rest } = score;
return {
@@ -34,8 +46,12 @@ export const convertScoreToPublicApi = <T extends ScoreDomain>(
};
}
if (score.dataType === ScoreDataTypeEnum.TEXT) {
return removeObjectKeys(score, ["longStringValue", "value"]);
}
return removeObjectKeys(score, ["longStringValue"]);
};
}
export type ScoreQueryType = {
page: number;
+2 -2
View File
@@ -13,7 +13,7 @@ import {
shouldSkipObservationsFinal,
} from "@langfuse/shared/src/server";
import {
AGGREGATABLE_SCORE_TYPES,
LISTABLE_SCORE_TYPES,
type OrderByState,
tracesTableCols,
} from "@langfuse/shared";
@@ -376,7 +376,7 @@ async function buildTracesBaseQuery(
...appliedEnvironmentFilter.params,
...appliedFilter.params,
projectId: props.projectId,
dataTypes: AGGREGATABLE_SCORE_TYPES,
dataTypes: LISTABLE_SCORE_TYPES,
...(props.limit !== undefined ? { limit: props.limit } : {}),
...(props.page !== undefined
? { offset: (props.page - 1) * props.limit }
@@ -1,6 +1,7 @@
import {
BooleanConfigFields,
CategoricalConfigFields,
TextConfigFields,
jsonSchema,
NumericConfigFields,
paginationMetaResponseZod,
@@ -53,6 +54,10 @@ const APIScoreConfig = z
...ScoreConfigBase.shape,
...BooleanConfigFields.shape,
}),
z.object({
...ScoreConfigBase.shape,
...TextConfigFields.shape,
}),
])
.superRefine(validateNumericRangeFields);
@@ -92,6 +97,15 @@ export const PostScoreConfigBody = z
categories: z.undefined(),
}).shape,
}),
z.object({
...PostScoreConfigBase.shape,
...z.object({
dataType: z.literal("TEXT"),
categories: z.undefined(),
maxValue: z.undefined().nullish(),
minValue: z.undefined().nullish(),
}).shape,
}),
])
.superRefine(validateNumericRangeFields);
+14 -1
View File
@@ -89,10 +89,11 @@ export const GetTracesV1Query = z.object({
.nullish()
.transform((v) => {
if (!v) return null;
return v
const parsed = v
.split(",")
.map((f) => f.trim())
.filter((f) => TRACE_FIELD_GROUPS.includes(f as TraceFieldGroup));
return parsed.length > 0 ? parsed : null;
})
.pipe(z.array(z.enum(TRACE_FIELD_GROUPS)).nullable()),
useEventsTable: useEventsTableSchema,
@@ -125,6 +126,18 @@ export const PostTracesV1Response = z.object({ id: z.string() });
// GET /api/public/traces/{traceId}
export const GetTraceV1Query = z.object({
traceId: z.string(),
fields: z
.string()
.nullish()
.transform((v) => {
if (!v) return null;
const parsed = v
.split(",")
.map((f) => f.trim())
.filter((f) => TRACE_FIELD_GROUPS.includes(f as TraceFieldGroup));
return parsed.length > 0 ? parsed : null;
})
.pipe(z.array(z.enum(TRACE_FIELD_GROUPS)).nullable()),
});
export const GetTraceV1Response = APIExtendedTrace.extend({
scores: z.array(APIScoreSchemaV1),
@@ -34,6 +34,7 @@ import { Textarea } from "@/src/components/ui/textarea";
import {
isBooleanDataType,
isCategoricalDataType,
isTextDataType,
isNumericDataType,
} from "@/src/features/scores/lib/helpers";
import DocPopup from "@/src/components/layouts/doc-popup";
@@ -192,16 +193,19 @@ export function UpsertScoreConfigDialog({
disabled={!!id}
defaultValue={field.value}
onValueChange={(value) => {
field.onChange(value as ScoreConfigDataType);
const dt = value as ScoreConfigDataType;
field.onChange(dt);
form.clearErrors();
if (isNumericDataType(value as ScoreConfigDataType)) {
if (isNumericDataType(dt)) {
form.setValue("categories", undefined);
} else if (isTextDataType(dt)) {
form.setValue("categories", undefined);
form.setValue("minValue", undefined);
form.setValue("maxValue", undefined);
} else {
form.setValue("minValue", undefined);
form.setValue("maxValue", undefined);
if (
isBooleanDataType(value as ScoreConfigDataType)
) {
if (isBooleanDataType(dt)) {
replace([
{ label: "True", value: 1 },
{ label: "False", value: 0 },
@@ -282,7 +286,7 @@ export function UpsertScoreConfigDialog({
)}
/>
</>
) : (
) : isTextDataType(form.getValues("dataType")) ? null : (
<div className="grid grid-flow-row gap-2">
<FormField
control={form.control}
@@ -4,7 +4,7 @@ import { z } from "zod";
export const createConfigSchema = z.object({
name: z.string().min(1).max(35),
description: z.string().optional(),
dataType: z.enum(["CATEGORICAL", "NUMERIC", "BOOLEAN"]),
dataType: z.enum(["CATEGORICAL", "NUMERIC", "BOOLEAN", "TEXT"]),
minValue: z.coerce.number().optional(),
maxValue: z.coerce.number().optional(),
categories: z.array(ScoreConfigCategory).optional(),
@@ -2,6 +2,7 @@ import {
NumericConfigFields,
CategoricalConfigFields,
BooleanConfigFields,
TextConfigFields,
validateNumericRangeFields,
} from "@langfuse/shared";
import { z } from "zod";
@@ -19,6 +20,7 @@ const ScoreConfigValidationSchema = ScoreConfigBaseSchema.and(
NumericConfigFields,
CategoricalConfigFields,
BooleanConfigFields,
TextConfigFields,
])
.superRefine(validateNumericRangeFields),
);
@@ -25,6 +25,7 @@ import {
type ScoreConfigCategoryDomain,
type UpdateAnnotationScoreData,
type CreateAnnotationScoreData,
TEXT_SCORE_MAX_LENGTH,
} from "@langfuse/shared";
import { Input } from "@/src/components/ui/input";
import {
@@ -39,6 +40,7 @@ import { HoverCardContent } from "@radix-ui/react-hover-card";
import { HoverCard, HoverCardTrigger } from "@/src/components/ui/hover-card";
import {
formatAnnotateDescription,
isTextDataType,
isNumericDataType,
isScoreUnsaved,
} from "@/src/features/scores/lib/helpers";
@@ -295,10 +297,17 @@ function InnerAnnotationForm<Target extends ScoreTarget>({
form.setValue(`scoreData.${index}.stringValue`, previousScore.stringValue);
form.setValue(`scoreData.${index}.comment`, previousScore.comment);
form.setValue(`scoreData.${index}.timestamp`, previousScore.timestamp);
form.setError(`scoreData.${index}.value`, {
type: "server",
message: "Failed to delete score",
});
if (isTextDataType(field.dataType)) {
form.setError(`scoreData.${index}.stringValue`, {
type: "server",
message: "Failed to delete score",
});
} else {
form.setError(`scoreData.${index}.value`, {
type: "server",
message: "Failed to delete score",
});
}
};
const handleDeleteScore = (index: number) => {
@@ -314,7 +323,11 @@ function InnerAnnotationForm<Target extends ScoreTarget>({
};
// Optimistically clear form
form.clearErrors(`scoreData.${index}.value`);
if (isTextDataType(field.dataType)) {
form.clearErrors(`scoreData.${index}.stringValue`);
} else {
form.clearErrors(`scoreData.${index}.value`);
}
update(index, {
name: field.name,
dataType: field.dataType,
@@ -349,10 +362,17 @@ function InnerAnnotationForm<Target extends ScoreTarget>({
) => {
form.setValue(`scoreData.${index}.value`, previousValue);
form.setValue(`scoreData.${index}.stringValue`, previousStringValue);
form.setError(`scoreData.${index}.value`, {
type: "server",
message: "Failed to update score",
});
if (isTextDataType(controlledFields[index]?.dataType)) {
form.setError(`scoreData.${index}.stringValue`, {
type: "server",
message: "Failed to update score",
});
} else {
form.setError(`scoreData.${index}.value`, {
type: "server",
message: "Failed to update score",
});
}
};
const rollbackCreateError = (
@@ -366,10 +386,17 @@ function InnerAnnotationForm<Target extends ScoreTarget>({
form.setValue(`scoreData.${index}.timestamp`, previousTimestamp);
form.setValue(`scoreData.${index}.value`, previousValue);
form.setValue(`scoreData.${index}.stringValue`, previousStringValue);
form.setError(`scoreData.${index}.value`, {
type: "server",
message: "Failed to create score",
});
if (isTextDataType(controlledFields[index]?.dataType)) {
form.setError(`scoreData.${index}.stringValue`, {
type: "server",
message: "Failed to create score",
});
} else {
form.setError(`scoreData.${index}.value`, {
type: "server",
message: "Failed to create score",
});
}
};
const handleUpsert = (
@@ -487,6 +514,21 @@ function InnerAnnotationForm<Target extends ScoreTarget>({
handleUpsert(index, numericCategoryValue, stringValue);
};
const handleTextUpsert = (index: number) => {
const field = controlledFields[index];
const config = configs.find((c) => c.id === field.configId);
if (!config || !field) return;
if (!field.stringValue) {
if (field.id) {
handleDeleteScore(index);
}
return;
}
handleUpsert(index, 0, field.stringValue);
};
const rollbackCommentError = (
index: number,
field: (typeof controlledFields)[number],
@@ -676,7 +718,28 @@ function InnerAnnotationForm<Target extends ScoreTarget>({
</Popover>
</div>
<div className="grid grid-cols-[11fr_1fr] items-center py-1">
{isNumericDataType(score.dataType) ? (
{isTextDataType(score.dataType) ? (
<FormField
control={form.control}
name={`scoreData.${index}.stringValue`}
render={({ field }) => (
<FormItem>
<FormControl>
<Textarea
{...field}
value={field.value ?? ""}
maxLength={TEXT_SCORE_MAX_LENGTH}
className="text-xs"
disabled={isInputDisabled(config)}
placeholder="Enter free form text..."
onBlur={() => handleTextUpsert(index)}
/>
</FormControl>
<FormMessage className="text-xs" />
</FormItem>
)}
/>
) : isNumericDataType(score.dataType) ? (
<FormField
control={form.control}
name={`scoreData.${index}.value`}
@@ -4,7 +4,9 @@ import {
type ScoreSimplified,
type ScoreSourceType,
type ScoreDomain,
type AggregatableScoreDataType,
type ScoreDataTypeType,
type ListableScore,
type ListableScoreDataType,
} from "@langfuse/shared";
/**
@@ -22,7 +24,7 @@ export const composeAggregateScoreKey = ({
}: {
name: string;
source: ScoreSourceType;
dataType: AggregatableScoreDataType;
dataType: ScoreDataTypeType;
keyPrefix?: string;
}): string => {
const formattedName = normalizeScoreName(name);
@@ -34,13 +36,13 @@ export const decomposeAggregateScoreKey = (
): {
name: string;
source: ScoreSourceType;
dataType: AggregatableScoreDataType;
dataType: ScoreDataTypeType;
} => {
const [name, source, dataType] = key.split("-");
return {
name,
source: source as ScoreSourceType,
dataType: dataType as AggregatableScoreDataType,
dataType: dataType as ScoreDataTypeType,
};
};
@@ -51,7 +53,7 @@ export const getScoreLabelFromKey = (key: string): string => {
export type ScoreToAggregate =
| (Omit<ScoreDomain, "dataType"> & {
dataType: AggregatableScoreDataType;
dataType: ListableScore["dataType"];
hasMetadata?: boolean;
})
| (ScoreSimplified & {
@@ -64,9 +66,9 @@ export type ScoreToAggregate =
* aggregation logic (value counting vs numeric averaging).
*/
export const resolveAggregateType = (
dataType: AggregatableScoreDataType,
dataType: ListableScoreDataType,
): "NUMERIC" | "CATEGORICAL" => {
return dataType === "BOOLEAN" ? "CATEGORICAL" : dataType;
return dataType === "NUMERIC" ? "NUMERIC" : "CATEGORICAL";
};
export const aggregateScores = <T extends ScoreToAggregate>(
+3
View File
@@ -16,6 +16,9 @@ export const isCategoricalDataType = (dataType: ScoreDataTypeType) =>
export const isBooleanDataType = (dataType: ScoreDataTypeType) =>
dataType === ScoreDataTypeEnum.BOOLEAN;
export const isTextDataType = (dataType: ScoreDataTypeType) =>
dataType === ScoreDataTypeEnum.TEXT;
export const isScoreUnsaved = (scoreId?: string | null): boolean => !scoreId;
export const toOrderedScoresList = (list: ScoreData[]): ScoreData[] =>
@@ -169,6 +169,8 @@ export const getScoreDataTypeIcon = (dataType: ScoreDataTypeType): string => {
return "Ⓑ";
case "CORRECTION":
return "";
case "TEXT":
return "Aa";
}
};
@@ -109,12 +109,18 @@ function transformAggregates(
const scores: AnnotationScore[] = [];
Object.entries(mergedAggregates).forEach(([key, aggregate]) => {
const { name, source, dataType } = decomposeAggregateScoreKey(key);
const {
name,
source,
dataType: rawDataType,
} = decomposeAggregateScoreKey(key);
// Only ANNOTATION source can be edited, and must have single ID
// Multi-value aggregates (no id) are child observation scores - skip them
if (source !== "ANNOTATION" || !aggregate.id) return;
const dataType = rawDataType as AnnotationScoreDataType;
const config = configs.find(
(c) => normalizeScoreName(c.name) === name && c.dataType === dataType,
);
+1 -2
View File
@@ -8,7 +8,6 @@ import {
type ScoreAggregate,
type ScoreConfigDomain,
type ScoreDomain,
type AggregatableScoreDataType,
ScoreConfigDataType,
} from "@langfuse/shared";
import { type z } from "zod";
@@ -106,7 +105,7 @@ export type ScoreColumn = {
key: string;
name: string;
source: ScoreSourceType;
dataType: AggregatableScoreDataType;
dataType: AnnotationScoreDataType;
};
export type ScoreConfigSelection =
@@ -27,10 +27,9 @@ export default withMiddlewares({
const datasetItem = await getDatasetItemById({
projectId: auth.scope.projectId,
datasetItemId: datasetItemId,
status: "ACTIVE",
});
if (!datasetItem) {
throw new LangfuseNotFoundError("Dataset item not found or archived");
throw new LangfuseNotFoundError("Dataset item not found");
}
const dataset = await prisma.dataset.findUnique({
@@ -8,7 +8,11 @@ import {
PostDatasetItemsV1Response,
transformDbDatasetItemDomainToAPIDatasetItem,
} from "@/src/features/public-api/types/datasets";
import { LangfuseNotFoundError, Prisma } from "@langfuse/shared";
import {
LangfuseConflictError,
LangfuseNotFoundError,
Prisma,
} from "@langfuse/shared";
import {
createDatasetItemFilterState,
getDatasetItems,
@@ -82,12 +86,23 @@ export default withMiddlewares({
// When this constraint is violated, the database will upsert based on (id, projectId, datasetId).
// If this record does not exist, the database will throw an error.
logger.warn(
`Failed to upsert dataset item. Dataset item ${id} in project ${auth.scope.projectId} already exists for a different dataset than ${datasetName}`,
`Failed to upsert dataset item. Dataset item ${id} already exists for a different dataset than ${datasetName}`,
);
throw new LangfuseNotFoundError(
`The dataset item with id ${id} already exists in a dataset other than ${datasetName}`,
);
}
if (e.code === "P2002") {
// Unique constraint violation on (id, projectId, validFrom).
// This can happen when concurrent requests try to update the same dataset item
// and create versions with the same timestamp.
logger.warn(
`Failed to upsert dataset item due to version conflict. Dataset item ${id} was modified concurrently.`,
);
throw new LangfuseConflictError(
`Dataset item ${id ?? "new"} was modified concurrently. Please retry the request.`,
);
}
}
throw e;
}
@@ -116,6 +116,22 @@ export default withMiddlewares({
return {};
}
// Warn on oversized OTEL request bodies (16MB threshold)
const bodyBytes = body.byteLength;
if (bodyBytes > 16 * 1024 * 1024) {
let spanCount = 0;
for (const rs of resourceSpans) {
for (const ss of rs?.scopeSpans ?? []) {
spanCount += ss?.spans?.length ?? 0;
}
}
logger.warn("OTEL request body exceeds 16MB", {
projectId: auth.scope.projectId,
bodyBytes,
spanCount,
});
}
// Extract SDK headers for write path decision (supports both hyphen and underscore formats)
const sdkName = getLangfuseHeader(req.headers, "x-langfuse-sdk-name");
const sdkVersion = getLangfuseHeader(
+58 -22
View File
@@ -6,7 +6,10 @@ import {
GetTraceV1Response,
DeleteTraceV1Query,
DeleteTraceV1Response,
TRACE_FIELD_GROUPS,
type TraceFieldGroup,
} from "@/src/features/public-api/types/traces";
import { env } from "@/src/env.mjs";
import {
filterAndValidateDbTraceScoreList,
LangfuseNotFoundError,
@@ -29,11 +32,32 @@ export default withMiddlewares({
responseSchema: GetTraceV1Response,
fn: async ({ query, auth }) => {
const { traceId } = query;
let effectiveFields: readonly TraceFieldGroup[] =
query.fields ?? TRACE_FIELD_GROUPS;
if (!query.fields && env.LANGFUSE_API_TRACEBYID_DEFAULT_FIELDS) {
const parsed = env.LANGFUSE_API_TRACEBYID_DEFAULT_FIELDS.split(",")
.map((f) => f.trim())
.filter((f): f is TraceFieldGroup =>
TRACE_FIELD_GROUPS.includes(f as TraceFieldGroup),
);
if (parsed.length > 0) {
effectiveFields = parsed;
}
}
const requestedFields = effectiveFields;
const includeIO = requestedFields.includes("io");
const includeObservations = requestedFields.includes("observations");
const includeScores = requestedFields.includes("scores");
const includeMetrics = requestedFields.includes("metrics");
const trace = await getTraceById({
traceId,
projectId: auth.scope.projectId,
clickhouseFeatureTag: "tracing-public-api",
preferredClickhouseService: "ReadOnly",
excludeInputOutput: !includeIO,
excludeMetadata: !includeIO,
});
if (!trace) {
@@ -43,19 +67,23 @@ export default withMiddlewares({
}
const [observations, scores] = await Promise.all([
getObservationsForTrace({
traceId,
projectId: auth.scope.projectId,
timestamp: trace?.timestamp,
includeIO: true,
preferredClickhouseService: "ReadOnly",
}),
getScoresForTraces({
projectId: auth.scope.projectId,
traceIds: [traceId],
timestamp: trace?.timestamp,
preferredClickhouseService: "ReadOnly",
}),
includeObservations || includeMetrics
? getObservationsForTrace({
traceId,
projectId: auth.scope.projectId,
timestamp: trace?.timestamp,
includeIO: includeObservations,
preferredClickhouseService: "ReadOnly",
})
: Promise.resolve([]),
includeScores
? getScoresForTraces({
projectId: auth.scope.projectId,
traceIds: [traceId],
timestamp: trace?.timestamp,
preferredClickhouseService: "ReadOnly",
})
: Promise.resolve([]),
]);
const uniqueModels: string[] = Array.from(
@@ -129,16 +157,24 @@ export default withMiddlewares({
return {
...trace,
externalId: null,
scores: validatedScores,
latency: latencyMs !== undefined ? latencyMs / 1000 : 0,
observations: outObservations,
metadata: includeIO ? trace.metadata : {},
scores: includeScores ? validatedScores : [],
latency: includeMetrics
? latencyMs !== undefined
? latencyMs / 1000
: 0
: -1,
observations: includeObservations ? outObservations : [],
htmlPath: `/project/${auth.scope.projectId}/traces/${traceId}`,
totalCost: outObservations
.reduce(
(acc, obs) => acc.add(obs.calculatedTotalCost ?? new Decimal(0)),
new Decimal(0),
)
.toNumber(),
totalCost: includeMetrics
? outObservations
.reduce(
(acc, obs) =>
acc.add(obs.calculatedTotalCost ?? new Decimal(0)),
new Decimal(0),
)
.toNumber()
: -1,
};
},
}),
@@ -1,8 +1,8 @@
import { env } from "@/src/env.mjs";
import { aggregateScores } from "@/src/features/scores/lib/aggregateScores";
import {
AGGREGATABLE_SCORE_TYPES,
filterAndValidateDbScoreList,
LISTABLE_SCORE_TYPES,
} from "@langfuse/shared";
import {
getObservationsTableWithModelData,
@@ -43,7 +43,7 @@ export async function getAllGenerations({
const validatedScores = filterAndValidateDbScoreList({
scores,
dataTypes: AGGREGATABLE_SCORE_TYPES,
dataTypes: LISTABLE_SCORE_TYPES,
includeHasMetadata: true,
onParseError: traceException,
});
+7 -7
View File
@@ -19,7 +19,7 @@ import {
timeFilter,
type SessionOptions,
type ScoreDomain,
AGGREGATABLE_SCORE_TYPES,
LISTABLE_SCORE_TYPES,
} from "@langfuse/shared";
import { Prisma } from "@langfuse/shared/src/db";
import { TRPCError } from "@trpc/server";
@@ -127,7 +127,7 @@ const handleGetSessionById = async (input: {
const validatedScores = filterAndValidateDbScoreList({
scores,
dataTypes: AGGREGATABLE_SCORE_TYPES,
dataTypes: LISTABLE_SCORE_TYPES,
onParseError: traceException,
});
@@ -405,7 +405,7 @@ export const sessionRouter = createTRPCRouter({
const validatedScores = filterAndValidateDbScoreList({
scores,
dataTypes: AGGREGATABLE_SCORE_TYPES,
dataTypes: LISTABLE_SCORE_TYPES,
onParseError: traceException,
});
@@ -474,7 +474,7 @@ export const sessionRouter = createTRPCRouter({
const validatedScores = filterAndValidateDbScoreList({
scores,
dataTypes: AGGREGATABLE_SCORE_TYPES,
dataTypes: LISTABLE_SCORE_TYPES,
onParseError: traceException,
});
@@ -657,7 +657,7 @@ export const sessionRouter = createTRPCRouter({
const validatedScores: ScoreDomain[] = filterAndValidateDbScoreList({
scores,
dataTypes: AGGREGATABLE_SCORE_TYPES,
dataTypes: LISTABLE_SCORE_TYPES,
onParseError: traceException,
});
@@ -701,7 +701,7 @@ export const sessionRouter = createTRPCRouter({
const validatedScores: ScoreDomain[] = filterAndValidateDbScoreList({
scores,
dataTypes: AGGREGATABLE_SCORE_TYPES,
dataTypes: LISTABLE_SCORE_TYPES,
onParseError: traceException,
});
@@ -747,7 +747,7 @@ export const sessionRouter = createTRPCRouter({
const validatedScores = filterAndValidateDbScoreList({
scores,
dataTypes: AGGREGATABLE_SCORE_TYPES,
dataTypes: LISTABLE_SCORE_TYPES,
onParseError: traceException,
});
+4 -3
View File
@@ -22,8 +22,9 @@ import {
type Observation,
TracingSearchType,
type ScoreDomain,
AGGREGATABLE_SCORE_TYPES,
ScoreDataTypeArray,
ScoreDataTypeEnum,
LISTABLE_SCORE_TYPES,
} from "@langfuse/shared";
import {
traceException,
@@ -244,7 +245,7 @@ export const traceRouter = createTRPCRouter({
const validatedScores = filterAndValidateDbScoreList({
scores: traceScores,
dataTypes: AGGREGATABLE_SCORE_TYPES,
dataTypes: LISTABLE_SCORE_TYPES,
includeHasMetadata: true,
onParseError: traceException,
});
@@ -379,7 +380,7 @@ export const traceRouter = createTRPCRouter({
const validatedScores = filterAndValidateDbScoreList({
scores: traceScores,
dataTypes: [...AGGREGATABLE_SCORE_TYPES, ScoreDataTypeEnum.CORRECTION],
dataTypes: [...ScoreDataTypeArray],
onParseError: traceException,
});

Some files were not shown because too many files have changed in this diff Show More