mirror of
https://github.com/FJurmanovic/sugarr.git
synced 2026-11-11 07:41:08 +00:00
Compare commits
10 Commits
feature/ty
...
9a4d254968
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a4d254968 | |||
| df4ca0bf76 | |||
| 6f0300f038 | |||
| e47c57b50d | |||
| b615f08039 | |||
| a5120d5153 | |||
| 2ffba19392 | |||
| a537f22384 | |||
| a90f9dc19b | |||
| 83bb16e2a0 |
35
.gitignore
vendored
35
.gitignore
vendored
@@ -1,39 +1,5 @@
|
||||
# ---> Go
|
||||
# Compiled Object files, Static and Dynamic libs (Shared Objects)
|
||||
*.o
|
||||
*.a
|
||||
*.so
|
||||
|
||||
# Folders
|
||||
_obj
|
||||
_test
|
||||
vendor
|
||||
bin
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
.env
|
||||
|
||||
# Architecture specific extensions/prefixes
|
||||
*.[568vq]
|
||||
[568vq].out
|
||||
|
||||
*.cgo1.go
|
||||
*.cgo2.c
|
||||
_cgo_defun.c
|
||||
_cgo_gotypes.go
|
||||
_cgo_export.*
|
||||
|
||||
_testmain.go
|
||||
|
||||
*.exe
|
||||
*.test
|
||||
*.prof
|
||||
*.log
|
||||
*.db
|
||||
|
||||
# .Dockerfile
|
||||
|
||||
.zed
|
||||
|
||||
deploy.config
|
||||
@@ -41,3 +7,4 @@ deploy.config
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
*.log
|
||||
|
||||
24
Dockerfile
24
Dockerfile
@@ -1,11 +1,17 @@
|
||||
FROM golang:1.24-alpine AS build
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /sugarr ./cmd/sugarr
|
||||
RUN corepack enable
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
RUN pnpm build
|
||||
|
||||
FROM alpine:3.19
|
||||
RUN apk add --no-cache ca-certificates
|
||||
COPY --from=build /sugarr /usr/local/bin/
|
||||
ENTRYPOINT ["sugarr"]
|
||||
FROM node:22-alpine AS runtime
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
RUN corepack enable
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
COPY --from=build /app/dist ./dist
|
||||
ENTRYPOINT ["node", "dist/app/index.js"]
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
RUN corepack enable
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
RUN pnpm build
|
||||
|
||||
FROM node:22-alpine AS runtime
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
RUN corepack enable
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
COPY --from=build /app/dist ./dist
|
||||
ENTRYPOINT ["node", "dist/app/index.js"]
|
||||
22
README.md
22
README.md
@@ -6,15 +6,16 @@ similarity/frequency fallback, resolves them against TMDb, excludes anything
|
||||
already in your library or Radarr, and can auto-request the rest via Radarr.
|
||||
Runs on a cron schedule and/or as an HTTP API.
|
||||
|
||||
This repo currently has two implementations:
|
||||
Built with a TypeScript/OOP architecture (Node + Fastify + Langfuse): a
|
||||
reusable `src/core/` framework (HTTP client base classes, an LLM tool/pipeline
|
||||
abstraction with Langfuse-cached prompts, Fastify server scaffolding) and an
|
||||
`src/app/` layer with Sugarr's concrete clients/tools/pipeline/controllers.
|
||||
See `docs/superpowers/specs/2026-08-12-typescript-oop-rewrite-design.md` and
|
||||
`docs/superpowers/plans/2026-08-12-typescript-oop-rewrite.md` for the design
|
||||
and implementation plan (this repo previously also had a Go implementation,
|
||||
now removed in favor of this one).
|
||||
|
||||
- **`cmd/`, `internal/`** — the original Go implementation.
|
||||
- **`src/`** — a TypeScript/OOP rewrite (Node + Fastify + Langfuse), the one
|
||||
documented below. See `docs/superpowers/specs/2026-08-12-typescript-oop-rewrite-design.md`
|
||||
and `docs/superpowers/plans/2026-08-12-typescript-oop-rewrite.md` for the
|
||||
design and implementation plan.
|
||||
|
||||
## Running the TypeScript app
|
||||
## Running the app
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
@@ -36,12 +37,9 @@ See `.env.example` for the full list with defaults. At minimum you need:
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.node.yml up -d --build
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Uses `Dockerfile.node` / `docker-compose.node.yml`, kept separate from the Go
|
||||
app's `Dockerfile` / `docker-compose.yml`.
|
||||
|
||||
## Seeding Langfuse prompts
|
||||
|
||||
The app fetches two chat prompts from Langfuse by name — `movie-recommender`
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/fjurmanovic/sugarr/internal/model"
|
||||
"github.com/fjurmanovic/sugarr/internal/service"
|
||||
"github.com/fjurmanovic/sugarr/internal/utl/configs"
|
||||
"github.com/fjurmanovic/sugarr/internal/utl/server"
|
||||
"github.com/robfig/cron/v3"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const version = "0.0.1"
|
||||
|
||||
func main() {
|
||||
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
|
||||
|
||||
cfg, err := configs.Load()
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to load config")
|
||||
}
|
||||
|
||||
printBanner(cfg)
|
||||
|
||||
rec := service.NewRecommender(cfg)
|
||||
|
||||
// Cron scheduler
|
||||
if cfg.CronEnabled {
|
||||
c := cron.New()
|
||||
if _, err := c.AddFunc(cfg.CronSchedule, func() {
|
||||
log.Info().Msg("Cron: running recommendation pipeline")
|
||||
recs, err := rec.Run()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Cron: pipeline failed")
|
||||
return
|
||||
}
|
||||
if cfg.RadarrEnabled {
|
||||
rec.AutoRequest(recs)
|
||||
}
|
||||
logRecommendations(recs)
|
||||
}); err != nil {
|
||||
log.Fatal().Err(err).Msg("Invalid cron schedule")
|
||||
}
|
||||
c.Start()
|
||||
defer c.Stop()
|
||||
log.Info().Msgf("Cron scheduled: %s", cfg.CronSchedule)
|
||||
}
|
||||
|
||||
// HTTP API — blocks until server exits
|
||||
if cfg.APIEnabled {
|
||||
if err := server.Start(cfg, rec); err != nil {
|
||||
log.Fatal().Err(err).Msg("HTTP server failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// One-shot mode: no API, no cron
|
||||
if !cfg.CronEnabled {
|
||||
recs, err := rec.Run()
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Recommendation pipeline failed")
|
||||
}
|
||||
printTable(recs)
|
||||
return
|
||||
}
|
||||
|
||||
// Cron-only mode: block forever
|
||||
select {}
|
||||
}
|
||||
|
||||
func printBanner(cfg *configs.Config) {
|
||||
log.Info().Msg("sugarr starting")
|
||||
log.Info().Msgf(" Version: %s", version)
|
||||
log.Info().Msgf(" Jellyfin: %s (user: %s)", cfg.JellyfinURL, cfg.JellyfinUserID)
|
||||
log.Info().Msgf(" LLM: enabled=%v model=%s", cfg.LLMEnabled, cfg.LLMModel)
|
||||
log.Info().Msgf(" Radarr: enabled=%v", cfg.RadarrEnabled)
|
||||
log.Info().Msgf(" Cron: enabled=%v schedule=%s", cfg.CronEnabled, cfg.CronSchedule)
|
||||
log.Info().Msgf(" API: enabled=%v port=%d", cfg.APIEnabled, cfg.APIPort)
|
||||
log.Info().Msgf(" Count: %d recommendations", cfg.RecommendCount)
|
||||
}
|
||||
|
||||
func logRecommendations(recs []model.Recommendation) {
|
||||
log.Info().Msgf("Recommendations (%d):", len(recs))
|
||||
for i, r := range recs {
|
||||
log.Info().Msgf(" %d. %s (%d) [tmdb:%d] — %s", i+1, r.Title, r.Year, r.TMDbID, r.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func printTable(recs []model.Recommendation) {
|
||||
if len(recs) == 0 {
|
||||
fmt.Println("No recommendations found.")
|
||||
return
|
||||
}
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "#\tTitle\tYear\tTMDb ID\tReason")
|
||||
fmt.Fprintln(w, "-\t-----\t----\t-------\t------")
|
||||
for i, r := range recs {
|
||||
fmt.Fprintf(w, "%d\t%s\t%d\t%d\t%s\n", i+1, r.Title, r.Year, r.TMDbID, r.Reason)
|
||||
}
|
||||
w.Flush()
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
services:
|
||||
sugarr-node:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.node
|
||||
container_name: sugarr-node
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${API_PORT}:${API_PORT}"
|
||||
environment:
|
||||
JELLYFIN_URL: ${JELLYFIN_URL}
|
||||
JELLYFIN_API_KEY: ${JELLYFIN_API_KEY}
|
||||
JELLYFIN_USER_ID: ${JELLYFIN_USER_ID}
|
||||
TMDB_API_KEY: ${TMDB_API_KEY}
|
||||
LLM_ENABLED: ${LLM_ENABLED}
|
||||
LLM_BASE_URL: ${LLM_BASE_URL}
|
||||
LLM_MODEL: ${LLM_MODEL}
|
||||
LLM_API_KEY: ${LLM_API_KEY}
|
||||
RADARR_ENABLED: ${RADARR_ENABLED}
|
||||
RADARR_URL: ${RADARR_URL}
|
||||
RADARR_API_KEY: ${RADARR_API_KEY}
|
||||
RADARR_QUALITY_PROFILE_ID: ${RADARR_QUALITY_PROFILE_ID}
|
||||
RADARR_ROOT_FOLDER: ${RADARR_ROOT_FOLDER}
|
||||
CRON_ENABLED: ${CRON_ENABLED}
|
||||
CRON_SCHEDULE: ${CRON_SCHEDULE}
|
||||
API_ENABLED: ${API_ENABLED}
|
||||
API_PORT: ${API_PORT}
|
||||
SECRET_KEY: ${SECRET_KEY}
|
||||
RECOMMEND_COUNT: ${RECOMMEND_COUNT}
|
||||
RECOMMEND_MAX_RETRY: ${RECOMMEND_MAX_RETRY}
|
||||
MIN_RATED_MOVIES: ${MIN_RATED_MOVIES}
|
||||
LANGFUSE_PUBLIC_KEY: ${LANGFUSE_PUBLIC_KEY}
|
||||
LANGFUSE_SECRET_KEY: ${LANGFUSE_SECRET_KEY}
|
||||
LANGFUSE_BASE_URL: ${LANGFUSE_BASE_URL}
|
||||
LANGFUSE_PROMPT_CACHE_ENABLED: ${LANGFUSE_PROMPT_CACHE_ENABLED}
|
||||
networks:
|
||||
- media
|
||||
|
||||
networks:
|
||||
media:
|
||||
@@ -1,10 +1,12 @@
|
||||
services:
|
||||
sugarr:
|
||||
build: .
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: sugarr
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${API_PORT}:${API_PORT}"
|
||||
- '${API_PORT}:${API_PORT}'
|
||||
environment:
|
||||
JELLYFIN_URL: ${JELLYFIN_URL}
|
||||
JELLYFIN_API_KEY: ${JELLYFIN_API_KEY}
|
||||
@@ -23,13 +25,17 @@ services:
|
||||
CRON_SCHEDULE: ${CRON_SCHEDULE}
|
||||
API_ENABLED: ${API_ENABLED}
|
||||
API_PORT: ${API_PORT}
|
||||
RECOMMEND_COUNT: ${RECOMMEND_COUNT}
|
||||
MIN_RATED_MOVIES: ${MIN_RATED_MOVIES}
|
||||
SECRET_KEY: ${SECRET_KEY}
|
||||
RECOMMEND_COUNT: ${RECOMMEND_COUNT}
|
||||
RECOMMEND_MAX_RETRY: ${RECOMMEND_MAX_RETRY}
|
||||
|
||||
MIN_RATED_MOVIES: ${MIN_RATED_MOVIES}
|
||||
LANGFUSE_PUBLIC_KEY: ${LANGFUSE_PUBLIC_KEY}
|
||||
LANGFUSE_SECRET_KEY: ${LANGFUSE_SECRET_KEY}
|
||||
LANGFUSE_BASE_URL: ${LANGFUSE_BASE_URL}
|
||||
LANGFUSE_PROMPT_CACHE_ENABLED: ${LANGFUSE_PROMPT_CACHE_ENABLED}
|
||||
networks:
|
||||
- media
|
||||
- coolify
|
||||
|
||||
networks:
|
||||
media:
|
||||
coolify:
|
||||
external: true
|
||||
|
||||
16
go.mod
16
go.mod
@@ -1,16 +0,0 @@
|
||||
module github.com/fjurmanovic/sugarr
|
||||
|
||||
go 1.24.2
|
||||
|
||||
require (
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/kelseyhightower/envconfig v1.4.0
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/rs/zerolog v1.34.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
)
|
||||
22
go.sum
22
go.sum
@@ -1,22 +0,0 @@
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
|
||||
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
@@ -1,16 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/fjurmanovic/sugarr/internal/controller"
|
||||
"github.com/fjurmanovic/sugarr/internal/service"
|
||||
"github.com/fjurmanovic/sugarr/internal/utl/configs"
|
||||
)
|
||||
|
||||
// Init registers all routes on mux and wires up controllers.
|
||||
func Init(mux *http.ServeMux, cfg *configs.Config, rec *service.Recommender) {
|
||||
controller.NewHealthController(mux, cfg)
|
||||
controller.NewRecommendationsController(mux, cfg, rec)
|
||||
controller.NewRequestController(mux, cfg, rec)
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/fjurmanovic/sugarr/internal/model"
|
||||
"github.com/fjurmanovic/sugarr/internal/utl/configs"
|
||||
)
|
||||
|
||||
type JellyfinClient struct {
|
||||
url string
|
||||
apiKey string
|
||||
userID string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func NewJellyfinClient(cfg *configs.Config) *JellyfinClient {
|
||||
return &JellyfinClient{
|
||||
url: cfg.JellyfinURL,
|
||||
apiKey: cfg.JellyfinAPIKey,
|
||||
userID: cfg.JellyfinUserID,
|
||||
http: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
type jellyfinItemsResponse struct {
|
||||
Items []jellyfinItem `json:"Items"`
|
||||
}
|
||||
|
||||
type jellyfinItem struct {
|
||||
Name string `json:"Name"`
|
||||
ProductionYear int `json:"ProductionYear"`
|
||||
ProviderIds map[string]string `json:"ProviderIds"`
|
||||
Genres []string `json:"Genres"`
|
||||
CommunityRating float64 `json:"CommunityRating"`
|
||||
UserData jellyfinUserData `json:"UserData"`
|
||||
}
|
||||
|
||||
type jellyfinUserData struct {
|
||||
IsFavorite bool `json:"IsFavorite"`
|
||||
PlayCount int `json:"PlayCount"`
|
||||
}
|
||||
|
||||
func (c *JellyfinClient) get(path string, params url.Values) (*jellyfinItemsResponse, error) {
|
||||
u := fmt.Sprintf("%s%s?%s", c.url, path, params.Encode())
|
||||
req, err := http.NewRequest(http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", fmt.Sprintf("MediaBrowser Token=%q", c.apiKey))
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jellyfin request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("jellyfin returned status %d for %s", resp.StatusCode, path)
|
||||
}
|
||||
|
||||
var result jellyfinItemsResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("decoding jellyfin response: %w", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func toMovies(items []jellyfinItem) []model.Movie {
|
||||
movies := make([]model.Movie, 0, len(items))
|
||||
for _, item := range items {
|
||||
movies = append(movies, model.Movie{
|
||||
Name: item.Name,
|
||||
Year: item.ProductionYear,
|
||||
IMDbID: item.ProviderIds["Imdb"],
|
||||
TMDbID: item.ProviderIds["Tmdb"],
|
||||
Genres: item.Genres,
|
||||
CommunityRating: item.CommunityRating,
|
||||
IsFavorite: item.UserData.IsFavorite,
|
||||
PlayCount: item.UserData.PlayCount,
|
||||
})
|
||||
}
|
||||
return movies
|
||||
}
|
||||
|
||||
func (c *JellyfinClient) GetFavoriteMovies() ([]model.Movie, error) {
|
||||
params := url.Values{
|
||||
"IncludeItemTypes": {"Movie"},
|
||||
"Recursive": {"true"},
|
||||
"IsFavorite": {"true"},
|
||||
"Fields": {"ProviderIds,Genres,CommunityRating,UserData"},
|
||||
}
|
||||
resp, err := c.get(fmt.Sprintf("/Users/%s/Items", c.userID), params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toMovies(resp.Items), nil
|
||||
}
|
||||
|
||||
func (c *JellyfinClient) GetRecentlyWatched() ([]model.Movie, error) {
|
||||
params := url.Values{
|
||||
"IncludeItemTypes": {"Movie"},
|
||||
"Recursive": {"true"},
|
||||
"Filters": {"IsPlayed"},
|
||||
"SortBy": {"DatePlayed"},
|
||||
"SortOrder": {"Descending"},
|
||||
"Limit": {"50"},
|
||||
"Fields": {"ProviderIds,Genres,CommunityRating,UserData"},
|
||||
}
|
||||
resp, err := c.get(fmt.Sprintf("/Users/%s/Items", c.userID), params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toMovies(resp.Items), nil
|
||||
}
|
||||
|
||||
func (c *JellyfinClient) GetAllMovies() ([]model.Movie, error) {
|
||||
params := url.Values{
|
||||
"IncludeItemTypes": {"Movie"},
|
||||
"Recursive": {"true"},
|
||||
"Fields": {"ProviderIds"},
|
||||
"Limit": {"10000"},
|
||||
}
|
||||
resp, err := c.get(fmt.Sprintf("/Users/%s/Items", c.userID), params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toMovies(resp.Items), nil
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/fjurmanovic/sugarr/internal/model"
|
||||
"github.com/fjurmanovic/sugarr/internal/utl/configs"
|
||||
)
|
||||
|
||||
type LLMClient struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
model string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func NewLLMClient(cfg *configs.Config) *LLMClient {
|
||||
return &LLMClient{
|
||||
baseURL: cfg.LLMBaseURL,
|
||||
apiKey: cfg.LLMAPIKey,
|
||||
model: cfg.LLMModel,
|
||||
http: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
type chatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []chatMessage `json:"messages"`
|
||||
}
|
||||
|
||||
type chatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type chatResponse struct {
|
||||
Choices []struct {
|
||||
Message chatMessage `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
func (c *LLMClient) Recommend(prompt string) ([]model.MovieSuggestion, error) {
|
||||
payload := chatRequest{
|
||||
Model: c.model,
|
||||
Messages: []chatMessage{{Role: "user", Content: prompt}},
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, c.baseURL+"/chat/completions", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if c.apiKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("llm request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var chatResp interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil {
|
||||
return nil, fmt.Errorf("decoding llm response: %w", err)
|
||||
}
|
||||
return nil, fmt.Errorf("llm returned status %d with body: %s", resp.StatusCode, chatResp)
|
||||
}
|
||||
|
||||
var chatResp chatResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil {
|
||||
return nil, fmt.Errorf("decoding llm response: %w", err)
|
||||
}
|
||||
if len(chatResp.Choices) == 0 {
|
||||
return nil, fmt.Errorf("llm returned no choices")
|
||||
}
|
||||
|
||||
return parseMovieSuggestions(chatResp.Choices[0].Message.Content)
|
||||
}
|
||||
|
||||
// parseMovieSuggestions extracts a JSON array from LLM output,
|
||||
// handling markdown code fences and surrounding prose.
|
||||
func parseMovieSuggestions(content string) ([]model.MovieSuggestion, error) {
|
||||
content = strings.TrimSpace(content)
|
||||
|
||||
// Strip markdown fences
|
||||
if idx := strings.Index(content, "```"); idx != -1 {
|
||||
content = content[idx:]
|
||||
content = strings.TrimPrefix(content, "```json")
|
||||
content = strings.TrimPrefix(content, "```")
|
||||
if end := strings.Index(content, "```"); end != -1 {
|
||||
content = content[:end]
|
||||
}
|
||||
content = strings.TrimSpace(content)
|
||||
}
|
||||
|
||||
// Find JSON array boundaries
|
||||
start := strings.Index(content, "[")
|
||||
end := strings.LastIndex(content, "]")
|
||||
if start == -1 || end == -1 || end <= start {
|
||||
return nil, fmt.Errorf("no JSON array found in llm response: %s", content)
|
||||
}
|
||||
|
||||
var suggestions []model.MovieSuggestion
|
||||
if err := json.Unmarshal([]byte(content[start:end+1]), &suggestions); err != nil {
|
||||
return nil, fmt.Errorf("parsing llm json: %w", err)
|
||||
}
|
||||
return suggestions, nil
|
||||
}
|
||||
|
||||
// BuildPrompt constructs the recommendation prompt sent to the LLM.
|
||||
// previouslySuggested contains movies returned in prior retry attempts that must not be repeated.
|
||||
func BuildPrompt(favorites, watched, library, previouslySuggested []model.Movie, count int) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf(
|
||||
"You are a movie recommendation engine. Based on the user's favorite and recently watched movies below, suggest exactly %d movies they would enjoy.\n\n"+
|
||||
"IMPORTANT:\n"+
|
||||
"- Do NOT suggest any movie from the \"Already In Library\" list.\n"+
|
||||
"- Do NOT suggest any movie from the \"Already Suggested\" list.\n"+
|
||||
"- Return ONLY a JSON array, no other text.\n"+
|
||||
"- Each entry: {\"title\": \"Movie Title\", \"year\": 2024, \"reason\": \"brief reason\"}\n\n"+
|
||||
"User's favorite movies:\n", count))
|
||||
|
||||
if len(favorites) == 0 {
|
||||
sb.WriteString("(none)\n")
|
||||
} else {
|
||||
for _, m := range favorites {
|
||||
sb.WriteString(fmt.Sprintf("- %s (%d)\n", m.Name, m.Year))
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("\nUser's recently watched movies:\n")
|
||||
if len(watched) == 0 {
|
||||
sb.WriteString("(none)\n")
|
||||
} else {
|
||||
for _, m := range watched {
|
||||
sb.WriteString(fmt.Sprintf("- %s (%d)\n", m.Name, m.Year))
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("\nAlready in library (DO NOT suggest these):\n")
|
||||
for _, m := range library {
|
||||
sb.WriteString(fmt.Sprintf("- %s (%d)\n", m.Name, m.Year))
|
||||
}
|
||||
|
||||
if len(previouslySuggested) > 0 {
|
||||
sb.WriteString("\nAlready suggested (DO NOT repeat these):\n")
|
||||
for _, m := range previouslySuggested {
|
||||
sb.WriteString(fmt.Sprintf("- %s (%d)\n", m.Name, m.Year))
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/fjurmanovic/sugarr/internal/utl/configs"
|
||||
)
|
||||
|
||||
type RadarrMovie struct {
|
||||
Title string `json:"title"`
|
||||
TmdbId int `json:"tmdbId"`
|
||||
QualityProfileId int `json:"qualityProfileId"`
|
||||
RootFolderPath string `json:"rootFolderPath"`
|
||||
Monitored bool `json:"monitored"`
|
||||
AddOptions radarrAddOptions `json:"addOptions"`
|
||||
}
|
||||
|
||||
type radarrAddOptions struct {
|
||||
SearchForMovie bool `json:"searchForMovie"`
|
||||
}
|
||||
|
||||
type RadarrClient struct {
|
||||
url string
|
||||
apiKey string
|
||||
qualityProfile int
|
||||
rootFolder string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func NewRadarrClient(cfg *configs.Config) *RadarrClient {
|
||||
return &RadarrClient{
|
||||
url: cfg.RadarrURL,
|
||||
apiKey: cfg.RadarrAPIKey,
|
||||
qualityProfile: cfg.RadarrQualityProfile,
|
||||
rootFolder: cfg.RadarrRootFolder,
|
||||
http: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *RadarrClient) newRequest(method, path string, body []byte) (*http.Request, error) {
|
||||
var req *http.Request
|
||||
var err error
|
||||
if body != nil {
|
||||
req, err = http.NewRequest(method, c.url+path, bytes.NewReader(body))
|
||||
} else {
|
||||
req, err = http.NewRequest(method, c.url+path, nil)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("X-Api-Key", c.apiKey)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (c *RadarrClient) Lookup(tmdbID int) (*RadarrMovie, error) {
|
||||
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/v3/movie/lookup/tmdb?tmdbId=%d", tmdbID), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("radarr lookup failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("radarr lookup returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var movie RadarrMovie
|
||||
if err := json.NewDecoder(resp.Body).Decode(&movie); err != nil {
|
||||
return nil, fmt.Errorf("decoding radarr lookup response: %w", err)
|
||||
}
|
||||
movie.QualityProfileId = c.qualityProfile
|
||||
movie.RootFolderPath = c.rootFolder
|
||||
movie.Monitored = true
|
||||
movie.AddOptions = radarrAddOptions{SearchForMovie: true}
|
||||
return &movie, nil
|
||||
}
|
||||
|
||||
// AddMovie adds a movie to Radarr. Treats HTTP 400 (already exists) as success.
|
||||
func (c *RadarrClient) AddMovie(movie RadarrMovie) error {
|
||||
body, err := json.Marshal(movie)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := c.newRequest(http.MethodPost, "/api/v3/movie", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("radarr add movie failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusBadRequest {
|
||||
return fmt.Errorf("radarr add movie returned status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *RadarrClient) GetExisting() ([]RadarrMovie, error) {
|
||||
req, err := c.newRequest(http.MethodGet, "/api/v3/movie", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("radarr get existing failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("radarr get existing returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var movies []RadarrMovie
|
||||
if err := json.NewDecoder(resp.Body).Decode(&movies); err != nil {
|
||||
return nil, fmt.Errorf("decoding radarr movies: %w", err)
|
||||
}
|
||||
return movies, nil
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/fjurmanovic/sugarr/internal/model"
|
||||
"github.com/fjurmanovic/sugarr/internal/utl/configs"
|
||||
)
|
||||
|
||||
const tmdbBaseURL = "https://api.themoviedb.org"
|
||||
|
||||
type TMDbClient struct {
|
||||
apiKey string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func NewTMDbClient(cfg *configs.Config) *TMDbClient {
|
||||
return &TMDbClient{
|
||||
apiKey: cfg.TMDbAPIKey,
|
||||
http: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
type tmdbSearchResponse struct {
|
||||
Results []tmdbMovieJSON `json:"results"`
|
||||
}
|
||||
|
||||
type tmdbMovieJSON struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
}
|
||||
|
||||
func (m tmdbMovieJSON) toModel() model.TMDbMovie {
|
||||
year := 0
|
||||
if len(m.ReleaseDate) >= 4 {
|
||||
year, _ = strconv.Atoi(m.ReleaseDate[:4])
|
||||
}
|
||||
return model.TMDbMovie{ID: m.ID, Title: m.Title, Year: year}
|
||||
}
|
||||
|
||||
func (c *TMDbClient) get(path string, params url.Values) (*http.Response, error) {
|
||||
params.Set("api_key", c.apiKey)
|
||||
u := fmt.Sprintf("%s%s?%s", tmdbBaseURL, path, params.Encode())
|
||||
req, err := http.NewRequest(http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tmdb request failed: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("tmdb returned status %d for %s", resp.StatusCode, path)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *TMDbClient) SearchMovie(title string, year int) (*model.TMDbMovie, error) {
|
||||
params := url.Values{"query": {title}}
|
||||
if year > 0 {
|
||||
params.Set("year", strconv.Itoa(year))
|
||||
}
|
||||
resp, err := c.get("/3/search/movie", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result tmdbSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("decoding tmdb search response: %w", err)
|
||||
}
|
||||
if len(result.Results) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
m := result.Results[0].toModel()
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func (c *TMDbClient) getMovieList(path string) ([]model.TMDbMovie, error) {
|
||||
resp, err := c.get(path, url.Values{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result tmdbSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("decoding tmdb response: %w", err)
|
||||
}
|
||||
movies := make([]model.TMDbMovie, 0, len(result.Results))
|
||||
for _, r := range result.Results {
|
||||
movies = append(movies, r.toModel())
|
||||
}
|
||||
return movies, nil
|
||||
}
|
||||
|
||||
func (c *TMDbClient) GetSimilar(tmdbID int) ([]model.TMDbMovie, error) {
|
||||
return c.getMovieList(fmt.Sprintf("/3/movie/%d/similar", tmdbID))
|
||||
}
|
||||
|
||||
func (c *TMDbClient) GetRecommendations(tmdbID int) ([]model.TMDbMovie, error) {
|
||||
return c.getMovieList(fmt.Sprintf("/3/movie/%d/recommendations", tmdbID))
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/fjurmanovic/sugarr/internal/utl/configs"
|
||||
"github.com/fjurmanovic/sugarr/internal/utl/httputil"
|
||||
)
|
||||
|
||||
type HealthController struct {
|
||||
cfg *configs.Config
|
||||
}
|
||||
|
||||
func NewHealthController(mux *http.ServeMux, cfg *configs.Config) *HealthController {
|
||||
c := &HealthController{cfg: cfg}
|
||||
mux.HandleFunc("GET /health", c.handle)
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *HealthController) handle(w http.ResponseWriter, _ *http.Request) {
|
||||
httputil.WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"status": "ok",
|
||||
"llm": c.cfg.LLMEnabled,
|
||||
"radarr": c.cfg.RadarrEnabled,
|
||||
})
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/fjurmanovic/sugarr/internal/model"
|
||||
"github.com/fjurmanovic/sugarr/internal/service"
|
||||
"github.com/fjurmanovic/sugarr/internal/utl/configs"
|
||||
"github.com/fjurmanovic/sugarr/internal/utl/httputil"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type RecommendationsController struct {
|
||||
cfg *configs.Config
|
||||
rec *service.Recommender
|
||||
}
|
||||
|
||||
func NewRecommendationsController(mux *http.ServeMux, cfg *configs.Config, rec *service.Recommender) *RecommendationsController {
|
||||
c := &RecommendationsController{cfg: cfg, rec: rec}
|
||||
mux.HandleFunc("GET /recommendations", c.handleList)
|
||||
mux.HandleFunc("POST /recommendations/search", c.handleSearch)
|
||||
mux.HandleFunc("POST /recommendations/trigger", c.handleTrigger)
|
||||
return c
|
||||
}
|
||||
|
||||
// handleList runs the recommendation pipeline and returns results as JSON.
|
||||
// Query params: ?count=10&force_llm=true&force_tmdb=true
|
||||
func (c *RecommendationsController) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
count := c.cfg.RecommendCount
|
||||
if v := r.URL.Query().Get("count"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
count = n
|
||||
}
|
||||
}
|
||||
|
||||
// Temporarily override LLM flag for force_llm / force_tmdb params
|
||||
origLLM := c.cfg.LLMEnabled
|
||||
if r.URL.Query().Get("force_llm") == "true" {
|
||||
c.cfg.LLMEnabled = true
|
||||
} else if r.URL.Query().Get("force_tmdb") == "true" {
|
||||
c.cfg.LLMEnabled = false
|
||||
}
|
||||
|
||||
recs, err := c.rec.RunWithCount(count)
|
||||
c.cfg.LLMEnabled = origLLM
|
||||
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Recommendation pipeline failed")
|
||||
httputil.WriteError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
httputil.WriteJSON(w, http.StatusOK, recs)
|
||||
}
|
||||
|
||||
// handleTrigger runs the same pipeline as the cron job: recommend + auto-request if Radarr is enabled.
|
||||
func (c *RecommendationsController) handleTrigger(w http.ResponseWriter, r *http.Request) {
|
||||
log.Info().Msg("Trigger: running recommendation pipeline")
|
||||
recs, err := c.rec.Run()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Trigger: pipeline failed")
|
||||
httputil.WriteError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if c.cfg.RadarrEnabled {
|
||||
c.rec.AutoRequest(recs)
|
||||
}
|
||||
httputil.WriteJSON(w, http.StatusOK, recs)
|
||||
}
|
||||
|
||||
// handleSearch performs a natural-language LLM search and resolves results via TMDb.
|
||||
// Body: {"query": "sci-fi movies like Blade Runner but more recent"}
|
||||
func (c *RecommendationsController) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
if !c.cfg.LLMEnabled || c.rec.LLM() == nil {
|
||||
httputil.WriteError(w, http.StatusBadRequest, "LLM is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Query string `json:"query"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil || body.Query == "" {
|
||||
httputil.WriteError(w, http.StatusBadRequest, "body must be {\"query\": \"...\"}")
|
||||
return
|
||||
}
|
||||
|
||||
prompt := fmt.Sprintf(
|
||||
"You are a movie recommendation engine. The user is looking for: %s\n\n"+
|
||||
"Return ONLY a JSON array of %d movies matching this request. No other text.\n"+
|
||||
"Each entry: {\"title\": \"Movie Title\", \"year\": 2024, \"reason\": \"brief reason\"}",
|
||||
body.Query, c.cfg.RecommendCount)
|
||||
|
||||
suggestions, err := c.rec.LLM().Recommend(prompt)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("LLM search failed")
|
||||
httputil.WriteError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
library, _ := c.rec.Jellyfin().GetAllMovies()
|
||||
exclude := map[int]bool{}
|
||||
for _, m := range library {
|
||||
if id, err := strconv.Atoi(m.TMDbID); err == nil && id != 0 {
|
||||
exclude[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
var recs []model.Recommendation
|
||||
for _, sug := range suggestions {
|
||||
if len(recs) >= c.cfg.RecommendCount {
|
||||
break
|
||||
}
|
||||
tm, err := c.rec.TMDb().SearchMovie(sug.Title, sug.Year)
|
||||
if err != nil || tm == nil || exclude[tm.ID] {
|
||||
continue
|
||||
}
|
||||
exclude[tm.ID] = true
|
||||
recs = append(recs, model.Recommendation{
|
||||
Title: tm.Title,
|
||||
Year: tm.Year,
|
||||
TMDbID: tm.ID,
|
||||
Reason: sug.Reason,
|
||||
})
|
||||
}
|
||||
httputil.WriteJSON(w, http.StatusOK, recs)
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/fjurmanovic/sugarr/internal/service"
|
||||
"github.com/fjurmanovic/sugarr/internal/utl/configs"
|
||||
"github.com/fjurmanovic/sugarr/internal/utl/httputil"
|
||||
)
|
||||
|
||||
type RequestController struct {
|
||||
cfg *configs.Config
|
||||
rec *service.Recommender
|
||||
}
|
||||
|
||||
func NewRequestController(mux *http.ServeMux, cfg *configs.Config, rec *service.Recommender) *RequestController {
|
||||
c := &RequestController{cfg: cfg, rec: rec}
|
||||
mux.HandleFunc("POST /recommendations/request", c.handle)
|
||||
return c
|
||||
}
|
||||
|
||||
// handle adds a movie to Radarr by TMDb ID.
|
||||
// Body: {"tmdb_id": 12345}
|
||||
func (c *RequestController) handle(w http.ResponseWriter, r *http.Request) {
|
||||
if !c.cfg.RadarrEnabled || c.rec.Radarr() == nil {
|
||||
httputil.WriteError(w, http.StatusBadRequest, "Radarr is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
TMDbID int `json:"tmdb_id"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil || body.TMDbID == 0 {
|
||||
httputil.WriteError(w, http.StatusBadRequest, "body must be {\"tmdb_id\": 12345}")
|
||||
return
|
||||
}
|
||||
|
||||
movie, err := c.rec.Radarr().Lookup(body.TMDbID)
|
||||
if err != nil {
|
||||
httputil.WriteError(w, http.StatusInternalServerError, fmt.Sprintf("radarr lookup failed: %s", err))
|
||||
return
|
||||
}
|
||||
if err := c.rec.Radarr().AddMovie(*movie); err != nil {
|
||||
httputil.WriteError(w, http.StatusInternalServerError, fmt.Sprintf("radarr add failed: %s", err))
|
||||
return
|
||||
}
|
||||
httputil.WriteJSON(w, http.StatusOK, map[string]any{"requested": true, "title": movie.Title})
|
||||
}
|
||||
|
||||
func decodeJSON(r *http.Request, v any) error {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(body, v)
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package model
|
||||
|
||||
// Movie represents a movie from the Jellyfin library.
|
||||
type Movie struct {
|
||||
Name string
|
||||
Year int
|
||||
IMDbID string
|
||||
TMDbID string
|
||||
Genres []string
|
||||
CommunityRating float64
|
||||
IsFavorite bool
|
||||
PlayCount int
|
||||
}
|
||||
|
||||
// TMDbMovie is a movie record returned by the TMDb API.
|
||||
type TMDbMovie struct {
|
||||
ID int
|
||||
Title string
|
||||
Year int
|
||||
}
|
||||
|
||||
// MovieSuggestion is a raw suggestion from the LLM or TMDb fallback.
|
||||
type MovieSuggestion struct {
|
||||
Title string `json:"title"`
|
||||
Year int `json:"year"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// Recommendation is a resolved movie recommendation ready to return to the user.
|
||||
type Recommendation struct {
|
||||
Title string `json:"title"`
|
||||
Year int `json:"year"`
|
||||
TMDbID int `json:"tmdb_id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"github.com/fjurmanovic/sugarr/internal/client"
|
||||
"github.com/fjurmanovic/sugarr/internal/model"
|
||||
"github.com/fjurmanovic/sugarr/internal/utl/configs"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// Recommender orchestrates the full recommendation pipeline.
|
||||
type Recommender struct {
|
||||
cfg *configs.Config
|
||||
jellyfin *client.JellyfinClient
|
||||
tmdb *client.TMDbClient
|
||||
llm *client.LLMClient // nil if LLM disabled
|
||||
radarr *client.RadarrClient // nil if Radarr disabled
|
||||
}
|
||||
|
||||
func NewRecommender(cfg *configs.Config) *Recommender {
|
||||
r := &Recommender{
|
||||
cfg: cfg,
|
||||
jellyfin: client.NewJellyfinClient(cfg),
|
||||
tmdb: client.NewTMDbClient(cfg),
|
||||
}
|
||||
if cfg.LLMEnabled {
|
||||
r.llm = client.NewLLMClient(cfg)
|
||||
}
|
||||
if cfg.RadarrEnabled {
|
||||
r.radarr = client.NewRadarrClient(cfg)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *Recommender) Run() ([]model.Recommendation, error) {
|
||||
return r.RunWithCount(r.cfg.RecommendCount)
|
||||
}
|
||||
|
||||
func (r *Recommender) RunWithCount(count int) ([]model.Recommendation, error) {
|
||||
// 1. Determine signal movies
|
||||
favorites, err := r.jellyfin.GetFavoriteMovies()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching favorites: %w", err)
|
||||
}
|
||||
|
||||
var signalMovies, watchedMovies []model.Movie
|
||||
|
||||
if len(favorites) >= r.cfg.MinRatedMovies {
|
||||
signalMovies = favorites
|
||||
log.Info().Msgf("Using %d favorite movies as signal", len(favorites))
|
||||
} else {
|
||||
watchedMovies, err = r.jellyfin.GetRecentlyWatched()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching recently watched: %w", err)
|
||||
}
|
||||
signalMovies = watchedMovies
|
||||
log.Info().Msgf("Not enough favorites (%d < %d), falling back to %d recently watched",
|
||||
len(favorites), r.cfg.MinRatedMovies, len(watchedMovies))
|
||||
}
|
||||
|
||||
// 2. Build exclusion set from Jellyfin library
|
||||
library, err := r.jellyfin.GetAllMovies()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Could not fetch library; exclusion may be incomplete")
|
||||
}
|
||||
exclude := buildExclusionSet(library)
|
||||
|
||||
// Also exclude movies already in Radarr
|
||||
if r.cfg.RadarrEnabled && r.radarr != nil {
|
||||
if existing, err := r.radarr.GetExisting(); err != nil {
|
||||
log.Warn().Err(err).Msg("Could not fetch Radarr library; exclusion may be incomplete")
|
||||
} else {
|
||||
for _, m := range existing {
|
||||
exclude[m.TmdbId] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Retry loop: each attempt requests only the remaining needed count.
|
||||
// `seen` accumulates every TMDb-resolved movie across attempts so the
|
||||
// LLM prompt excludes them by name, not just by ID.
|
||||
var recommendations []model.Recommendation
|
||||
var seen []model.Movie
|
||||
for attempt := 0; attempt <= r.cfg.RecommendMaxRetry && len(recommendations) < count; attempt++ {
|
||||
remaining := count - len(recommendations)
|
||||
if attempt > 0 {
|
||||
log.Info().Msgf("Retry %d/%d: need %d more recommendations", attempt, r.cfg.RecommendMaxRetry, remaining)
|
||||
}
|
||||
|
||||
var suggestions []model.MovieSuggestion
|
||||
if r.cfg.LLMEnabled && r.llm != nil {
|
||||
prompt := client.BuildPrompt(favorites, watchedMovies, library, seen, remaining)
|
||||
suggestions, err = r.llm.Recommend(prompt)
|
||||
if err != nil {
|
||||
return recommendations, fmt.Errorf("llm recommendation failed: %w", err)
|
||||
}
|
||||
log.Info().Msgf("LLM returned %d suggestions (attempt %d)", len(suggestions), attempt+1)
|
||||
} else {
|
||||
suggestions = r.tmdbFallback(signalMovies, exclude, remaining)
|
||||
log.Info().Msgf("TMDb fallback returned %d suggestions (attempt %d)", len(suggestions), attempt+1)
|
||||
}
|
||||
|
||||
if len(suggestions) == 0 {
|
||||
log.Warn().Msgf("No suggestions returned on attempt %d, stopping retries", attempt+1)
|
||||
break
|
||||
}
|
||||
|
||||
before := len(recommendations)
|
||||
for _, s := range suggestions {
|
||||
if len(recommendations) >= count {
|
||||
break
|
||||
}
|
||||
tm, err := r.tmdb.SearchMovie(s.Title, s.Year)
|
||||
if err != nil || tm == nil {
|
||||
log.Debug().Msgf("Could not resolve: %s (%d)", s.Title, s.Year)
|
||||
continue
|
||||
}
|
||||
seen = append(seen, model.Movie{Name: tm.Title, Year: tm.Year, TMDbID: strconv.Itoa(tm.ID)})
|
||||
if exclude[tm.ID] {
|
||||
log.Debug().Msgf("Skipping (already in library): %s", tm.Title)
|
||||
continue
|
||||
}
|
||||
exclude[tm.ID] = true
|
||||
recommendations = append(recommendations, model.Recommendation{
|
||||
Title: tm.Title,
|
||||
Year: tm.Year,
|
||||
TMDbID: tm.ID,
|
||||
Reason: s.Reason,
|
||||
})
|
||||
}
|
||||
|
||||
if len(recommendations) == before {
|
||||
log.Info().Msgf("All suggestions on attempt %d were already in library, retrying", attempt+1)
|
||||
}
|
||||
}
|
||||
|
||||
return recommendations, nil
|
||||
}
|
||||
|
||||
// tmdbFallback gathers TMDb similar/recommended movies across signal movies,
|
||||
// ranks by occurrence frequency, and returns top N suggestions.
|
||||
func (r *Recommender) tmdbFallback(signals []model.Movie, exclude map[int]bool, count int) []model.MovieSuggestion {
|
||||
freq := map[int]int{}
|
||||
meta := map[int]model.TMDbMovie{}
|
||||
|
||||
for _, m := range signals {
|
||||
tmdbID, err := strconv.Atoi(m.TMDbID)
|
||||
if err != nil || tmdbID == 0 {
|
||||
continue
|
||||
}
|
||||
for _, list := range [][]model.TMDbMovie{
|
||||
mustGetList(r.tmdb.GetSimilar(tmdbID)),
|
||||
mustGetList(r.tmdb.GetRecommendations(tmdbID)),
|
||||
} {
|
||||
for _, tm := range list {
|
||||
if !exclude[tm.ID] {
|
||||
freq[tm.ID]++
|
||||
meta[tm.ID] = tm
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type entry struct{ id, count int }
|
||||
sorted := make([]entry, 0, len(freq))
|
||||
for id, c := range freq {
|
||||
sorted = append(sorted, entry{id, c})
|
||||
}
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].count > sorted[j].count })
|
||||
|
||||
suggestions := make([]model.MovieSuggestion, 0, count)
|
||||
for _, e := range sorted {
|
||||
if len(suggestions) >= count {
|
||||
break
|
||||
}
|
||||
tm := meta[e.id]
|
||||
suggestions = append(suggestions, model.MovieSuggestion{
|
||||
Title: tm.Title,
|
||||
Year: tm.Year,
|
||||
Reason: "Recommended by TMDb",
|
||||
})
|
||||
}
|
||||
return suggestions
|
||||
}
|
||||
|
||||
// AutoRequest adds all recommendations to Radarr.
|
||||
func (r *Recommender) AutoRequest(recs []model.Recommendation) {
|
||||
if r.radarr == nil {
|
||||
return
|
||||
}
|
||||
for _, rec := range recs {
|
||||
movie, err := r.radarr.Lookup(rec.TMDbID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("Radarr lookup failed for %s", rec.Title)
|
||||
continue
|
||||
}
|
||||
if err := r.radarr.AddMovie(*movie); err != nil {
|
||||
log.Error().Err(err).Msgf("Radarr add failed for %s", rec.Title)
|
||||
continue
|
||||
}
|
||||
log.Info().Msgf("Requested in Radarr: %s (%d)", rec.Title, rec.Year)
|
||||
}
|
||||
}
|
||||
|
||||
// LLM returns the LLM client, or nil if not enabled.
|
||||
func (r *Recommender) LLM() *client.LLMClient { return r.llm }
|
||||
|
||||
// Radarr returns the Radarr client, or nil if not enabled.
|
||||
func (r *Recommender) Radarr() *client.RadarrClient { return r.radarr }
|
||||
|
||||
// Jellyfin returns the Jellyfin client.
|
||||
func (r *Recommender) Jellyfin() *client.JellyfinClient { return r.jellyfin }
|
||||
|
||||
// TMDb returns the TMDb client.
|
||||
func (r *Recommender) TMDb() *client.TMDbClient { return r.tmdb }
|
||||
|
||||
func buildExclusionSet(movies []model.Movie) map[int]bool {
|
||||
exclude := map[int]bool{}
|
||||
for _, m := range movies {
|
||||
if id, err := strconv.Atoi(m.TMDbID); err == nil && id != 0 {
|
||||
exclude[id] = true
|
||||
}
|
||||
}
|
||||
return exclude
|
||||
}
|
||||
|
||||
func mustGetList(movies []model.TMDbMovie, _ error) []model.TMDbMovie {
|
||||
if movies == nil {
|
||||
return nil
|
||||
}
|
||||
return movies
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package configs
|
||||
|
||||
import (
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/kelseyhightower/envconfig"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
JellyfinURL string `envconfig:"JELLYFIN_URL" required:"true"`
|
||||
JellyfinAPIKey string `envconfig:"JELLYFIN_API_KEY" required:"true"`
|
||||
JellyfinUserID string `envconfig:"JELLYFIN_USER_ID" required:"true"`
|
||||
TMDbAPIKey string `envconfig:"TMDB_API_KEY" required:"true"`
|
||||
|
||||
LLMEnabled bool `envconfig:"LLM_ENABLED" default:"false"`
|
||||
LLMBaseURL string `envconfig:"LLM_BASE_URL" default:"http://localhost:11434/v1"`
|
||||
LLMAPIKey string `envconfig:"LLM_API_KEY" default:""`
|
||||
LLMModel string `envconfig:"LLM_MODEL" default:"llama3"`
|
||||
|
||||
RadarrEnabled bool `envconfig:"RADARR_ENABLED" default:"false"`
|
||||
RadarrURL string `envconfig:"RADARR_URL"`
|
||||
RadarrAPIKey string `envconfig:"RADARR_API_KEY"`
|
||||
RadarrQualityProfile int `envconfig:"RADARR_QUALITY_PROFILE_ID" default:"1"`
|
||||
RadarrRootFolder string `envconfig:"RADARR_ROOT_FOLDER" default:"/movies"`
|
||||
|
||||
CronEnabled bool `envconfig:"CRON_ENABLED" default:"false"`
|
||||
CronSchedule string `envconfig:"CRON_SCHEDULE" default:"0 3 * * *"`
|
||||
|
||||
APIEnabled bool `envconfig:"API_ENABLED" default:"true"`
|
||||
APIPort int `envconfig:"API_PORT" default:"8080"`
|
||||
APISecretKey string `envconfig:"SECRET_KEY" default:""`
|
||||
|
||||
RecommendCount int `envconfig:"RECOMMEND_COUNT" default:"10"`
|
||||
RecommendMaxRetry int `envconfig:"RECOMMEND_MAX_RETRY" default:"3"`
|
||||
MinRatedMovies int `envconfig:"MIN_RATED_MOVIES" default:"5"`
|
||||
}
|
||||
|
||||
// Load reads .env (best-effort) then processes environment variables into a Config.
|
||||
func Load() (*Config, error) {
|
||||
_ = godotenv.Load()
|
||||
var cfg Config
|
||||
if err := envconfig.Process("", &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package httputil
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func WriteJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func WriteError(w http.ResponseWriter, status int, msg string) {
|
||||
WriteJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// AuthMiddleware validates the Authorization: Bearer <key> header.
|
||||
// If secretKey is empty, auth is disabled and all requests pass through.
|
||||
// /health is always exempt.
|
||||
func AuthMiddleware(secretKey string, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if secretKey == "" || r.URL.Path == "/health" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
if !ok || token != secretKey {
|
||||
WriteError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/fjurmanovic/sugarr/internal/api"
|
||||
"github.com/fjurmanovic/sugarr/internal/service"
|
||||
"github.com/fjurmanovic/sugarr/internal/utl/configs"
|
||||
"github.com/fjurmanovic/sugarr/internal/utl/httputil"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// Start registers all routes and begins listening. Blocks until the server exits.
|
||||
func Start(cfg *configs.Config, rec *service.Recommender) error {
|
||||
mux := http.NewServeMux()
|
||||
api.Init(mux, cfg, rec)
|
||||
|
||||
addr := fmt.Sprintf(":%d", cfg.APIPort)
|
||||
log.Info().Msgf("HTTP API listening on %s", addr)
|
||||
if cfg.APISecretKey != "" {
|
||||
log.Info().Msg("Secret key auth enabled")
|
||||
}
|
||||
return http.ListenAndServe(addr, httputil.AuthMiddleware(cfg.APISecretKey, mux))
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "dist/app/index.js",
|
||||
"packageManager": "pnpm@11.21.0",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/app/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
@@ -13,9 +14,12 @@
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@langfuse/client": "^5.10.0",
|
||||
"@langfuse/otel": "^5.10.0",
|
||||
"@langfuse/tracing": "^5.10.0",
|
||||
"@opentelemetry/sdk-node": "^0.221.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"fastify": "^5.0.0",
|
||||
"langfuse": "^3.30.0",
|
||||
"node-cron": "^3.0.3",
|
||||
"openai": "^4.60.0",
|
||||
"pino": "^9.4.0",
|
||||
|
||||
707
pnpm-lock.yaml
generated
707
pnpm-lock.yaml
generated
@@ -8,15 +8,24 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@langfuse/client':
|
||||
specifier: ^5.10.0
|
||||
version: 5.10.0(@opentelemetry/api@1.9.1)
|
||||
'@langfuse/otel':
|
||||
specifier: ^5.10.0
|
||||
version: 5.10.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.221.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))
|
||||
'@langfuse/tracing':
|
||||
specifier: ^5.10.0
|
||||
version: 5.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/sdk-node':
|
||||
specifier: ^0.221.0
|
||||
version: 0.221.0(@opentelemetry/api@1.9.1)(supports-color@7.2.0)
|
||||
dotenv:
|
||||
specifier: ^16.4.5
|
||||
version: 16.6.1
|
||||
fastify:
|
||||
specifier: ^5.0.0
|
||||
version: 5.11.3
|
||||
langfuse:
|
||||
specifier: ^3.30.0
|
||||
version: 3.38.20
|
||||
node-cron:
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.3
|
||||
@@ -393,6 +402,15 @@ packages:
|
||||
'@fastify/proxy-addr@5.1.0':
|
||||
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
|
||||
|
||||
'@grpc/grpc-js@1.14.4':
|
||||
resolution: {integrity: sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==}
|
||||
engines: {node: '>=12.10.0'}
|
||||
|
||||
'@grpc/proto-loader@0.8.1':
|
||||
resolution: {integrity: sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==}
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
'@humanwhocodes/config-array@0.13.0':
|
||||
resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==}
|
||||
engines: {node: '>=10.10.0'}
|
||||
@@ -409,6 +427,34 @@ packages:
|
||||
'@jridgewell/sourcemap-codec@1.5.5':
|
||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||
|
||||
'@js-sdsl/ordered-map@4.4.2':
|
||||
resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==}
|
||||
|
||||
'@langfuse/client@5.10.0':
|
||||
resolution: {integrity: sha512-WI//PooxMyFEFtDy77XNv6WxOpd52U6HNgI8cqhPX+S2aJ8Mz2UTWz7olwzLlrwToJpsy5aZNtpdhsol2Umubw==}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
|
||||
'@langfuse/core@5.10.0':
|
||||
resolution: {integrity: sha512-XXz1DBEwGMpNXz8DZHFsixqo+3vBkfYpat3oLLldRMuT8ur6OWQ81Kgh5Obh0CZ31adaR4FrLTHh4N9eBXo64A==}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
|
||||
'@langfuse/otel@5.10.0':
|
||||
resolution: {integrity: sha512-q1expgCm3QwjWII0PeCJJSyEV5o96FQ4VNzL5hgziWGmCPCd9TrrWxWxHdSY7cpZWddEJCp71zdh1nq0xISOpQ==}
|
||||
engines: {node: '>=20'}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
'@opentelemetry/core': ^2.0.1
|
||||
'@opentelemetry/exporter-trace-otlp-http': '>=0.202.0 <1.0.0'
|
||||
'@opentelemetry/sdk-trace-base': ^2.0.1
|
||||
|
||||
'@langfuse/tracing@5.10.0':
|
||||
resolution: {integrity: sha512-//S9dcZszmEk2yx+h7eg10mEMswyU7MYLkGIJlO6gUfrXA+3Y5pvNiZNZU3cfDus94OAr16dHQpg3nNFiziZjg==}
|
||||
engines: {node: '>=20'}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
|
||||
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
||||
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
|
||||
engines: {node: ^22.20 || ^24.12 || >=25}
|
||||
@@ -428,9 +474,210 @@ packages:
|
||||
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
'@opentelemetry/api-logs@0.221.0':
|
||||
resolution: {integrity: sha512-OlanaW1vv7ufTqQ3/fPLI4arGt5ZoM+P8abOMki6uEYnpRazepSWDwDnnw+la7kE26SHVC18//SMccrDvLKOXQ==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
'@opentelemetry/api@1.9.1':
|
||||
resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
'@opentelemetry/configuration@0.221.0':
|
||||
resolution: {integrity: sha512-uE9y56Zdi9Gt/RdxYnVOo3YmFZkKJJMA0gqtBe8wh8gdtF5Asqe+Oh/TWiDtFb1s+31jNY4CWgnfIB1KOITfFA==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
|
||||
'@opentelemetry/context-async-hooks@2.10.0':
|
||||
resolution: {integrity: sha512-bvyMcgLEkozzSzpEEEo1OMoeQ97bxj6Qs2uN3mPrSdDvObMI1myffD/BPqcLlzZO9//d1SqQA/WPw7Cz2AiqhA==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': '>=1.0.0 <1.10.0'
|
||||
|
||||
'@opentelemetry/core@2.10.0':
|
||||
resolution: {integrity: sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': '>=1.0.0 <1.10.0'
|
||||
|
||||
'@opentelemetry/exporter-logs-otlp-grpc@0.221.0':
|
||||
resolution: {integrity: sha512-txG1G0IrYSsKKMeiWZfj/i5cQmWB+h+hf3HzPpF3RqZVwp+iQQEIsv8Vtmzy6RWVdHdJZfygmVrBI39YTBvWcw==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.3.0
|
||||
|
||||
'@opentelemetry/exporter-logs-otlp-http@0.221.0':
|
||||
resolution: {integrity: sha512-nKXkr4Tomi6fjYVOf+ytcW3dZAVr4v4Bv5gsT6dr2gvpUPJpKgHB4XbMufMsPotRE3g0XH2GwVVCkN2w6SON+Q==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.3.0
|
||||
|
||||
'@opentelemetry/exporter-logs-otlp-proto@0.221.0':
|
||||
resolution: {integrity: sha512-AH6EY+47gXFaWYgG3hfeOneGiE9xIZGtDBk+9g0sM8NZWzsQhhmqPbQQXJzS7pyCh5jRRr2nYNXVrkCmoojRvQ==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.3.0
|
||||
|
||||
'@opentelemetry/exporter-metrics-otlp-grpc@0.221.0':
|
||||
resolution: {integrity: sha512-KOgCtO15FC6C1T/xOqBcr7EyUs7B+7yomGNb5Y97d3s38rPbCCk5sewkmE2b0/itOkQ/PptX8CLlD+kn2mEtTg==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.3.0
|
||||
|
||||
'@opentelemetry/exporter-metrics-otlp-http@0.221.0':
|
||||
resolution: {integrity: sha512-sRfCKbOzgy8xZQV2as0RzIZlnCmCseCKZGLfRcrpo2CBngJDr+rPtX0zkG0+oUCV5kfQPUoW3W3C96Ag3Y/Clg==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.3.0
|
||||
|
||||
'@opentelemetry/exporter-metrics-otlp-proto@0.221.0':
|
||||
resolution: {integrity: sha512-YMF4LveY2I3yhw61rn6nmC9FE8U24IZHPeKU1Duc5+sbwjMd8FwZAwba318ImdThCg/HuVQvhm2y6bfgNPnfYg==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.3.0
|
||||
|
||||
'@opentelemetry/exporter-prometheus@0.221.0':
|
||||
resolution: {integrity: sha512-kW79a20qWESIuAdDrxzg9WKM98twV/NBWBFRAH57ap/+ssZhiCo0hckzKT0zpuwR/gSHrFAQhJL0bYDrnEM34g==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.3.0
|
||||
|
||||
'@opentelemetry/exporter-trace-otlp-grpc@0.221.0':
|
||||
resolution: {integrity: sha512-zXminlZedtq9LvOW64CnNkOqk15zV75k8JgtdTuWFge6+jk2m4GmAUm6L2eIiG1o2a2bZxXw2PDrszm+bps0IA==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.3.0
|
||||
|
||||
'@opentelemetry/exporter-trace-otlp-http@0.221.0':
|
||||
resolution: {integrity: sha512-AySXiKoC+meiWm6zdVj5T2LnPDZuatveBby1cMOeQteIWsYXAUxs8Sru13G2pVSPrUXz6vF+og7QVBX6GdC/oQ==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.3.0
|
||||
|
||||
'@opentelemetry/exporter-trace-otlp-proto@0.221.0':
|
||||
resolution: {integrity: sha512-Z9i2T7vgZbWe9rSLYxXVIbeW+XyzUq4rZanW3ZyVNwVDqCsh0EJKUgBWWQ0CZfeuUA+RQPzKgJQHMuWAUnKqXw==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.3.0
|
||||
|
||||
'@opentelemetry/exporter-zipkin@2.10.0':
|
||||
resolution: {integrity: sha512-7gsvgf0UDoJ4l9ObrwBmz5G/ZogiPk+lq+g5GpLp24YQF/vPM/BSsnOfcLnfinast5ASUgLo78uSC/ObjlnXgg==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.0.0
|
||||
|
||||
'@opentelemetry/instrumentation@0.221.0':
|
||||
resolution: {integrity: sha512-cCk80Z/iRDf/5gfsKMB4f74LqVA5yKETB/9ojPzVW/6/f70iu89nJvGxsFCxx4XfSohaOofkU19kiYm84AiAlw==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.3.0
|
||||
|
||||
'@opentelemetry/otlp-exporter-base@0.221.0':
|
||||
resolution: {integrity: sha512-UFPIq80OH3Ns/oPFHRj14d4DTOxUo+MUFU8hUiCq5jTqFhdeJnfVSANHT+xp92409cA+oxzvlZCe6NM1wvCuBA==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.3.0
|
||||
|
||||
'@opentelemetry/otlp-grpc-exporter-base@0.221.0':
|
||||
resolution: {integrity: sha512-rQDmNgyiGCTrescjnzH2ntVyUKVIq6I2UjuK8+stT/Xg0ZOT71FVJqwjFdspQl6Yol/Yqsut9bDo+ame8oTmDQ==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.3.0
|
||||
|
||||
'@opentelemetry/otlp-transformer@0.221.0':
|
||||
resolution: {integrity: sha512-lg6lkOU08Az23jVcn/0Els9HP+V8PnR4Km6p0KgpTggS0n/WuhnmY64rSh83Of9iR9nD+dpWr6adlcX8KzAwjg==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.3.0
|
||||
|
||||
'@opentelemetry/propagator-b3@2.10.0':
|
||||
resolution: {integrity: sha512-GnA5B24H+1w8BO21J0q+IWNB0z1v+AGbcquTdIt/dufibhnhgxaA8YKvz0I3akRZhB1jHT+/tlzK+qlAjEDybQ==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': '>=1.0.0 <1.10.0'
|
||||
|
||||
'@opentelemetry/propagator-jaeger@2.10.0':
|
||||
resolution: {integrity: sha512-yw/IX8DL470dSMZJoE82ScfYGp7JWZ/G8kFJo35ZILUVTB2jFPTOaioN+8s09pH0RHsWNhweVZb+ZnjJJpCChg==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': '>=1.0.0 <1.10.0'
|
||||
|
||||
'@opentelemetry/resources@2.10.0':
|
||||
resolution: {integrity: sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': '>=1.3.0 <1.10.0'
|
||||
|
||||
'@opentelemetry/sdk-logs@0.221.0':
|
||||
resolution: {integrity: sha512-FaDcazjyMp7TZZZAsqbo4IkovP0UegoCu0EBkiNt+qCqvUf7FPAsfcrZ3+ZEkKgXZ/jHafop+JoGPDk3A0SmLg==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': '>=1.4.0 <1.10.0'
|
||||
|
||||
'@opentelemetry/sdk-metrics@2.10.0':
|
||||
resolution: {integrity: sha512-t6r1VSvXNtSDnPXU1FbZeetJb7yyovHmgu0wRSoftxtE0g2rSNhQZQUy69sRUCL+iioJpX8SN/S6wq6ZtvLySQ==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': '>=1.9.0 <1.10.0'
|
||||
|
||||
'@opentelemetry/sdk-node@0.221.0':
|
||||
resolution: {integrity: sha512-UbYuvtBrQQB5Prsh9KOKy4kxzexFxfMs5MkteHeWMoswsEB7kiNhyUVkAOFW/qsEzNHtrkgyghrD2ilZJa+5YA==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': '>=1.3.0 <1.10.0'
|
||||
|
||||
'@opentelemetry/sdk-trace-base@2.10.0':
|
||||
resolution: {integrity: sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': '>=1.3.0 <1.10.0'
|
||||
|
||||
'@opentelemetry/sdk-trace-node@2.10.0':
|
||||
resolution: {integrity: sha512-GZK/G6oZyBLGlH1pUgeDch7D91KoHd2uotUGIkWCPi9GI5T9X0p4L7nNAMDR1BQjkRYoDqo+ddfVx9t5Uhys+Q==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': '>=1.0.0 <1.10.0'
|
||||
|
||||
'@opentelemetry/sdk-trace@2.10.0':
|
||||
resolution: {integrity: sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': '>=1.3.0 <1.10.0'
|
||||
|
||||
'@opentelemetry/semantic-conventions@1.43.0':
|
||||
resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@pinojs/redact@0.4.0':
|
||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||
|
||||
'@protobufjs/aspromise@1.1.2':
|
||||
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
|
||||
|
||||
'@protobufjs/base64@1.1.2':
|
||||
resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==}
|
||||
|
||||
'@protobufjs/codegen@2.0.5':
|
||||
resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==}
|
||||
|
||||
'@protobufjs/eventemitter@1.1.1':
|
||||
resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==}
|
||||
|
||||
'@protobufjs/fetch@1.1.1':
|
||||
resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==}
|
||||
|
||||
'@protobufjs/float@1.0.2':
|
||||
resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==}
|
||||
|
||||
'@protobufjs/path@1.1.2':
|
||||
resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==}
|
||||
|
||||
'@protobufjs/pool@1.1.0':
|
||||
resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==}
|
||||
|
||||
'@protobufjs/utf8@1.1.2':
|
||||
resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.62.4':
|
||||
resolution: {integrity: sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==}
|
||||
cpu: [arm]
|
||||
@@ -779,6 +1026,13 @@ packages:
|
||||
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
cjs-module-lexer@2.2.0:
|
||||
resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==}
|
||||
|
||||
cliui@8.0.1:
|
||||
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
@@ -843,6 +1097,9 @@ packages:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
emoji-regex@8.0.0:
|
||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||
|
||||
end-of-stream@1.4.5:
|
||||
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
|
||||
|
||||
@@ -857,6 +1114,9 @@ packages:
|
||||
es-module-lexer@1.7.0:
|
||||
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
|
||||
|
||||
es-module-lexer@2.3.1:
|
||||
resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==}
|
||||
|
||||
es-object-atoms@1.1.2:
|
||||
resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -875,6 +1135,10 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
escalade@3.2.0:
|
||||
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
escape-string-regexp@4.0.0:
|
||||
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1018,6 +1282,10 @@ packages:
|
||||
function-bind@1.1.2:
|
||||
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
||||
|
||||
get-caller-file@2.0.5:
|
||||
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||
engines: {node: 6.* || 8.* || >= 10.*}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1082,6 +1350,10 @@ packages:
|
||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
import-in-the-middle@3.3.3:
|
||||
resolution: {integrity: sha512-AiohS3H80sXO6owEltjGX+glb7qXaDhBoJb9XcQVH4UI207xu/bDLUcadVKp7Qe576reg9yr/PXZjV5qx8gfbA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
imurmurhash@0.1.4:
|
||||
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
|
||||
engines: {node: '>=0.8.19'}
|
||||
@@ -1101,6 +1373,10 @@ packages:
|
||||
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-fullwidth-code-point@3.0.0:
|
||||
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-glob@4.0.3:
|
||||
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1138,14 +1414,6 @@ packages:
|
||||
keyv@4.5.4:
|
||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||
|
||||
langfuse-core@3.38.20:
|
||||
resolution: {integrity: sha512-zBKVmQN/1oT5VWZUBYlWzvokIlkC/6mnpgr/2atMyTeAm+jR3ia7w2iJMjlrF5/oG8ukO1s8+LDRCzJpF1QeEA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
langfuse@3.38.20:
|
||||
resolution: {integrity: sha512-MAmBAASSzJtmK1O9HQegA1mFsQhT8Yf+OJRGvE7FXkyv3g/eiBE0glLD0Ohg3pkxhoPdggM5SejK7ue9ctlaMA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
levn@0.4.1:
|
||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -1157,9 +1425,15 @@ packages:
|
||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
lodash.camelcase@4.3.0:
|
||||
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
|
||||
|
||||
lodash.merge@4.6.2:
|
||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||
|
||||
long@5.3.2:
|
||||
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
|
||||
|
||||
loupe@3.2.1:
|
||||
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
||||
|
||||
@@ -1188,6 +1462,9 @@ packages:
|
||||
minimist@1.2.8:
|
||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||
|
||||
module-details-from-path@1.0.4:
|
||||
resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==}
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
@@ -1319,6 +1596,10 @@ packages:
|
||||
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
|
||||
engines: {node: '>= 0.6.0'}
|
||||
|
||||
protobufjs@7.6.5:
|
||||
resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
pump@3.0.4:
|
||||
resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
|
||||
|
||||
@@ -1340,10 +1621,18 @@ packages:
|
||||
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
|
||||
engines: {node: '>= 12.13.0'}
|
||||
|
||||
require-directory@2.1.1:
|
||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
require-from-string@2.0.2:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
require-in-the-middle@8.0.1:
|
||||
resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==}
|
||||
engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'}
|
||||
|
||||
resolve-from@4.0.0:
|
||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -1425,6 +1714,10 @@ packages:
|
||||
std-env@3.10.0:
|
||||
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
||||
|
||||
string-width@4.2.3:
|
||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
string_decoder@1.3.0:
|
||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||
|
||||
@@ -1598,9 +1891,30 @@ packages:
|
||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
y18n@5.0.8:
|
||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
yaml@2.9.0:
|
||||
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
yargs-parser@21.1.1:
|
||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
yargs@17.7.3:
|
||||
resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
yocto-queue@0.1.0:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1803,6 +2117,18 @@ snapshots:
|
||||
'@fastify/forwarded': 3.0.2
|
||||
ipaddr.js: 2.5.0
|
||||
|
||||
'@grpc/grpc-js@1.14.4':
|
||||
dependencies:
|
||||
'@grpc/proto-loader': 0.8.1
|
||||
'@js-sdsl/ordered-map': 4.4.2
|
||||
|
||||
'@grpc/proto-loader@0.8.1':
|
||||
dependencies:
|
||||
lodash.camelcase: 4.3.0
|
||||
long: 5.3.2
|
||||
protobufjs: 7.6.5
|
||||
yargs: 17.7.3
|
||||
|
||||
'@humanwhocodes/config-array@0.13.0(supports-color@7.2.0)':
|
||||
dependencies:
|
||||
'@humanwhocodes/object-schema': 2.0.3
|
||||
@@ -1817,6 +2143,32 @@ snapshots:
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||
|
||||
'@js-sdsl/ordered-map@4.4.2': {}
|
||||
|
||||
'@langfuse/client@5.10.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@langfuse/core': 5.10.0(@opentelemetry/api@1.9.1)
|
||||
'@langfuse/tracing': 5.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/api': 1.9.1
|
||||
mustache: 4.2.0
|
||||
|
||||
'@langfuse/core@5.10.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
|
||||
'@langfuse/otel@5.10.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.221.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))':
|
||||
dependencies:
|
||||
'@langfuse/core': 5.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/exporter-trace-otlp-http': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/sdk-trace-base': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
|
||||
'@langfuse/tracing@5.10.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@langfuse/core': 5.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/api': 1.9.1
|
||||
|
||||
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
||||
optional: true
|
||||
|
||||
@@ -1832,8 +2184,252 @@ snapshots:
|
||||
'@nodelib/fs.scandir': 2.1.5
|
||||
fastq: 1.20.1
|
||||
|
||||
'@opentelemetry/api-logs@0.221.0':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
|
||||
'@opentelemetry/api@1.9.1': {}
|
||||
|
||||
'@opentelemetry/configuration@0.221.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
yaml: 2.9.0
|
||||
|
||||
'@opentelemetry/context-async-hooks@2.10.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
|
||||
'@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/semantic-conventions': 1.43.0
|
||||
|
||||
'@opentelemetry/exporter-logs-otlp-grpc@0.221.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/otlp-exporter-base': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/otlp-grpc-exporter-base': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/otlp-transformer': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/sdk-logs': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
|
||||
'@opentelemetry/exporter-logs-otlp-http@0.221.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/otlp-exporter-base': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/otlp-transformer': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/sdk-logs': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
|
||||
'@opentelemetry/exporter-logs-otlp-proto@0.221.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/otlp-exporter-base': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/otlp-transformer': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/sdk-logs': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
|
||||
'@opentelemetry/exporter-metrics-otlp-grpc@0.221.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/exporter-metrics-otlp-http': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/otlp-grpc-exporter-base': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/otlp-transformer': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
|
||||
'@opentelemetry/exporter-metrics-otlp-http@0.221.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/otlp-exporter-base': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/otlp-transformer': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/sdk-metrics': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
|
||||
'@opentelemetry/exporter-metrics-otlp-proto@0.221.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/exporter-metrics-otlp-http': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/otlp-exporter-base': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/otlp-transformer': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
|
||||
'@opentelemetry/exporter-prometheus@0.221.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/sdk-metrics': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/semantic-conventions': 1.43.0
|
||||
|
||||
'@opentelemetry/exporter-trace-otlp-grpc@0.221.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/otlp-exporter-base': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/otlp-grpc-exporter-base': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/otlp-transformer': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/sdk-trace': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
|
||||
'@opentelemetry/exporter-trace-otlp-http@0.221.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/otlp-exporter-base': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/otlp-transformer': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/sdk-trace': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
|
||||
'@opentelemetry/exporter-trace-otlp-proto@0.221.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/otlp-exporter-base': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/otlp-transformer': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/sdk-trace': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
|
||||
'@opentelemetry/exporter-zipkin@2.10.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/sdk-trace': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/semantic-conventions': 1.43.0
|
||||
|
||||
'@opentelemetry/instrumentation@0.221.0(@opentelemetry/api@1.9.1)(supports-color@7.2.0)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/api-logs': 0.221.0
|
||||
import-in-the-middle: 3.3.3
|
||||
require-in-the-middle: 8.0.1(supports-color@7.2.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@opentelemetry/otlp-exporter-base@0.221.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/otlp-transformer': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
|
||||
'@opentelemetry/otlp-grpc-exporter-base@0.221.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@grpc/grpc-js': 1.14.4
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/otlp-exporter-base': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/otlp-transformer': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
|
||||
'@opentelemetry/otlp-transformer@0.221.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/api-logs': 0.221.0
|
||||
'@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/sdk-logs': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/sdk-metrics': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/sdk-trace': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
|
||||
'@opentelemetry/propagator-b3@2.10.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
|
||||
'@opentelemetry/propagator-jaeger@2.10.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
|
||||
'@opentelemetry/resources@2.10.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/semantic-conventions': 1.43.0
|
||||
|
||||
'@opentelemetry/sdk-logs@0.221.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/api-logs': 0.221.0
|
||||
'@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/semantic-conventions': 1.43.0
|
||||
|
||||
'@opentelemetry/sdk-metrics@2.10.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
|
||||
'@opentelemetry/sdk-node@0.221.0(@opentelemetry/api@1.9.1)(supports-color@7.2.0)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/api-logs': 0.221.0
|
||||
'@opentelemetry/configuration': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/context-async-hooks': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/exporter-logs-otlp-grpc': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/exporter-logs-otlp-http': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/exporter-logs-otlp-proto': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/exporter-metrics-otlp-grpc': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/exporter-metrics-otlp-http': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/exporter-metrics-otlp-proto': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/exporter-prometheus': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/exporter-trace-otlp-grpc': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/exporter-trace-otlp-http': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/exporter-trace-otlp-proto': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/exporter-zipkin': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1)(supports-color@7.2.0)
|
||||
'@opentelemetry/otlp-exporter-base': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/otlp-grpc-exporter-base': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/propagator-b3': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/propagator-jaeger': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/sdk-logs': 0.221.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/sdk-metrics': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/sdk-trace': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/sdk-trace-base': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/sdk-trace-node': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/semantic-conventions': 1.43.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/sdk-trace': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/semantic-conventions': 1.43.0
|
||||
|
||||
'@opentelemetry/sdk-trace-node@2.10.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/context-async-hooks': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/sdk-trace-base': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
|
||||
'@opentelemetry/sdk-trace@2.10.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/semantic-conventions': 1.43.0
|
||||
|
||||
'@opentelemetry/semantic-conventions@1.43.0': {}
|
||||
|
||||
'@pinojs/redact@0.4.0': {}
|
||||
|
||||
'@protobufjs/aspromise@1.1.2': {}
|
||||
|
||||
'@protobufjs/base64@1.1.2': {}
|
||||
|
||||
'@protobufjs/codegen@2.0.5': {}
|
||||
|
||||
'@protobufjs/eventemitter@1.1.1': {}
|
||||
|
||||
'@protobufjs/fetch@1.1.1':
|
||||
dependencies:
|
||||
'@protobufjs/aspromise': 1.1.2
|
||||
|
||||
'@protobufjs/float@1.0.2': {}
|
||||
|
||||
'@protobufjs/path@1.1.2': {}
|
||||
|
||||
'@protobufjs/pool@1.1.0': {}
|
||||
|
||||
'@protobufjs/utf8@1.1.2': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.62.4':
|
||||
optional: true
|
||||
|
||||
@@ -2156,6 +2752,14 @@ snapshots:
|
||||
|
||||
check-error@2.1.3: {}
|
||||
|
||||
cjs-module-lexer@2.2.0: {}
|
||||
|
||||
cliui@8.0.1:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
wrap-ansi: 7.0.0
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
@@ -2206,6 +2810,8 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
emoji-regex@8.0.0: {}
|
||||
|
||||
end-of-stream@1.4.5:
|
||||
dependencies:
|
||||
once: 1.4.0
|
||||
@@ -2216,6 +2822,8 @@ snapshots:
|
||||
|
||||
es-module-lexer@1.7.0: {}
|
||||
|
||||
es-module-lexer@2.3.1: {}
|
||||
|
||||
es-object-atoms@1.1.2:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
@@ -2282,6 +2890,8 @@ snapshots:
|
||||
'@esbuild/win32-ia32': 0.28.2
|
||||
'@esbuild/win32-x64': 0.28.2
|
||||
|
||||
escalade@3.2.0: {}
|
||||
|
||||
escape-string-regexp@4.0.0: {}
|
||||
|
||||
eslint-scope@7.2.2:
|
||||
@@ -2464,6 +3074,8 @@ snapshots:
|
||||
|
||||
function-bind@1.1.2: {}
|
||||
|
||||
get-caller-file@2.0.5: {}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
@@ -2532,6 +3144,12 @@ snapshots:
|
||||
parent-module: 1.0.1
|
||||
resolve-from: 4.0.0
|
||||
|
||||
import-in-the-middle@3.3.3:
|
||||
dependencies:
|
||||
cjs-module-lexer: 2.2.0
|
||||
es-module-lexer: 2.3.1
|
||||
module-details-from-path: 1.0.4
|
||||
|
||||
imurmurhash@0.1.4: {}
|
||||
|
||||
inflight@1.0.6:
|
||||
@@ -2545,6 +3163,8 @@ snapshots:
|
||||
|
||||
is-extglob@2.1.1: {}
|
||||
|
||||
is-fullwidth-code-point@3.0.0: {}
|
||||
|
||||
is-glob@4.0.3:
|
||||
dependencies:
|
||||
is-extglob: 2.1.1
|
||||
@@ -2575,14 +3195,6 @@ snapshots:
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
|
||||
langfuse-core@3.38.20:
|
||||
dependencies:
|
||||
mustache: 4.2.0
|
||||
|
||||
langfuse@3.38.20:
|
||||
dependencies:
|
||||
langfuse-core: 3.38.20
|
||||
|
||||
levn@0.4.1:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
@@ -2598,8 +3210,12 @@ snapshots:
|
||||
dependencies:
|
||||
p-locate: 5.0.0
|
||||
|
||||
lodash.camelcase@4.3.0: {}
|
||||
|
||||
lodash.merge@4.6.2: {}
|
||||
|
||||
long@5.3.2: {}
|
||||
|
||||
loupe@3.2.1: {}
|
||||
|
||||
magic-string@0.30.21:
|
||||
@@ -2624,6 +3240,8 @@ snapshots:
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
module-details-from-path@1.0.4: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
mustache@4.2.0: {}
|
||||
@@ -2750,6 +3368,20 @@ snapshots:
|
||||
|
||||
process@0.11.10: {}
|
||||
|
||||
protobufjs@7.6.5:
|
||||
dependencies:
|
||||
'@protobufjs/aspromise': 1.1.2
|
||||
'@protobufjs/base64': 1.1.2
|
||||
'@protobufjs/codegen': 2.0.5
|
||||
'@protobufjs/eventemitter': 1.1.1
|
||||
'@protobufjs/fetch': 1.1.1
|
||||
'@protobufjs/float': 1.0.2
|
||||
'@protobufjs/path': 1.1.2
|
||||
'@protobufjs/pool': 1.1.0
|
||||
'@protobufjs/utf8': 1.1.2
|
||||
'@types/node': 22.20.1
|
||||
long: 5.3.2
|
||||
|
||||
pump@3.0.4:
|
||||
dependencies:
|
||||
end-of-stream: 1.4.5
|
||||
@@ -2771,8 +3403,17 @@ snapshots:
|
||||
|
||||
real-require@0.2.0: {}
|
||||
|
||||
require-directory@2.1.1: {}
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
require-in-the-middle@8.0.1(supports-color@7.2.0):
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
module-details-from-path: 1.0.4
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
|
||||
ret@0.5.0: {}
|
||||
@@ -2857,6 +3498,12 @@ snapshots:
|
||||
|
||||
std-env@3.10.0: {}
|
||||
|
||||
string-width@4.2.3:
|
||||
dependencies:
|
||||
emoji-regex: 8.0.0
|
||||
is-fullwidth-code-point: 3.0.0
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
string_decoder@1.3.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
@@ -3006,8 +3653,30 @@ snapshots:
|
||||
|
||||
word-wrap@1.2.5: {}
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
y18n@5.0.8: {}
|
||||
|
||||
yaml@2.9.0: {}
|
||||
|
||||
yargs-parser@21.1.1: {}
|
||||
|
||||
yargs@17.7.3:
|
||||
dependencies:
|
||||
cliui: 8.0.1
|
||||
escalade: 3.2.0
|
||||
get-caller-file: 2.0.5
|
||||
require-directory: 2.1.1
|
||||
string-width: 4.2.3
|
||||
y18n: 5.0.8
|
||||
yargs-parser: 21.1.1
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
zod@3.25.76: {}
|
||||
|
||||
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
protobufjs: true
|
||||
@@ -40,7 +40,7 @@ export class RecommendationsController extends Controller {
|
||||
: this.config.LLM_ENABLED;
|
||||
|
||||
try {
|
||||
const recommendations = await this.pipeline.runAsync({ count, useLlm });
|
||||
const recommendations = await this.pipeline.runAsync({ count, useLlm }, { trigger: 'api-list' });
|
||||
reply.send(recommendations);
|
||||
} catch (err) {
|
||||
reply.code(500).send({ error: String(err) });
|
||||
@@ -49,10 +49,10 @@ export class RecommendationsController extends Controller {
|
||||
|
||||
private async handleTrigger(_request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const recommendations = await this.pipeline.runAsync({
|
||||
count: this.config.RECOMMEND_COUNT,
|
||||
useLlm: this.config.LLM_ENABLED,
|
||||
});
|
||||
const recommendations = await this.pipeline.runAsync(
|
||||
{ count: this.config.RECOMMEND_COUNT, useLlm: this.config.LLM_ENABLED },
|
||||
{ trigger: 'api-trigger' }
|
||||
);
|
||||
if (this.config.RADARR_ENABLED) {
|
||||
await this.pipeline.autoRequest(recommendations);
|
||||
}
|
||||
@@ -73,7 +73,7 @@ export class RecommendationsController extends Controller {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const recommendations = await this.pipeline.searchAsync(query, this.config.RECOMMEND_COUNT);
|
||||
const recommendations = await this.pipeline.searchAsync(query, this.config.RECOMMEND_COUNT, 'api-search');
|
||||
reply.send(recommendations);
|
||||
} catch (err) {
|
||||
reply.code(500).send({ error: String(err) });
|
||||
|
||||
@@ -9,10 +9,10 @@ export function scheduleCron(config: AppConfig, pipeline: RecommendationPipeline
|
||||
cron.schedule(config.CRON_SCHEDULE, async () => {
|
||||
logger.info('Cron: running recommendation pipeline');
|
||||
try {
|
||||
const recommendations = await pipeline.runAsync({
|
||||
count: config.RECOMMEND_COUNT,
|
||||
useLlm: config.LLM_ENABLED,
|
||||
});
|
||||
const recommendations = await pipeline.runAsync(
|
||||
{ count: config.RECOMMEND_COUNT, useLlm: config.LLM_ENABLED },
|
||||
{ trigger: 'cron' }
|
||||
);
|
||||
if (config.RADARR_ENABLED) {
|
||||
await pipeline.autoRequest(recommendations);
|
||||
}
|
||||
|
||||
@@ -32,10 +32,19 @@ async function main(): Promise<void> {
|
||||
const promptService = new PromptService(promptGateway, config.LANGFUSE_PROMPT_CACHE_ENABLED);
|
||||
const llmClient = new LlmClient({ baseUrl: config.LLM_BASE_URL, apiKey: config.LLM_API_KEY });
|
||||
|
||||
const jellyfin = new JellyfinClient(config.JELLYFIN_URL, config.JELLYFIN_API_KEY, config.JELLYFIN_USER_ID);
|
||||
const jellyfin = new JellyfinClient(
|
||||
config.JELLYFIN_URL,
|
||||
config.JELLYFIN_API_KEY,
|
||||
config.JELLYFIN_USER_ID
|
||||
);
|
||||
const tmdb = new TmdbClient(config.TMDB_API_KEY);
|
||||
const radarr = config.RADARR_ENABLED
|
||||
? new RadarrClient(config.RADARR_URL, config.RADARR_API_KEY, config.RADARR_QUALITY_PROFILE_ID, config.RADARR_ROOT_FOLDER)
|
||||
? new RadarrClient(
|
||||
config.RADARR_URL,
|
||||
config.RADARR_API_KEY,
|
||||
config.RADARR_QUALITY_PROFILE_ID,
|
||||
config.RADARR_ROOT_FOLDER
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const recommenderTool = new MovieRecommenderTool(promptService, llmClient, config.LLM_MODEL);
|
||||
@@ -58,12 +67,19 @@ async function main(): Promise<void> {
|
||||
radarrEnabled: config.RADARR_ENABLED,
|
||||
cronEnabled: config.CRON_ENABLED,
|
||||
apiEnabled: config.API_ENABLED,
|
||||
langfuse: config.LANGFUSE_BASE_URL,
|
||||
},
|
||||
'sugarr starting'
|
||||
);
|
||||
|
||||
scheduleCron(config, pipeline, logger);
|
||||
|
||||
const shutdown = () => {
|
||||
tracer.shutdown().catch((err) => logger.error({ err }, 'Error shutting down Langfuse tracer'));
|
||||
};
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
|
||||
if (config.API_ENABLED) {
|
||||
const app = buildServer({
|
||||
secretKey: config.SECRET_KEY,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { LlmPipeline } from '../../core/llm/LlmPipeline';
|
||||
import type { RunContext } from '../../core/llm/RunContext';
|
||||
import type { JellyfinClient } from '../clients/jellyfin/JellyfinClient';
|
||||
@@ -114,8 +115,16 @@ export class RecommendationPipeline extends LlmPipeline<RecommendationRequest, R
|
||||
}
|
||||
}
|
||||
|
||||
async searchAsync(query: string, count: number): Promise<Recommendation[]> {
|
||||
const trace = this.tracer.startTrace(`${this.name}-search`);
|
||||
async searchAsync(query: string, count: number, trigger?: string): Promise<Recommendation[]> {
|
||||
const sessionId = randomUUID();
|
||||
return this.tracer.runWithSession(
|
||||
{ sessionId, tags: trigger ? [trigger] : undefined },
|
||||
() => this.runSearch(query, count, trigger)
|
||||
);
|
||||
}
|
||||
|
||||
private async runSearch(query: string, count: number, trigger?: string): Promise<Recommendation[]> {
|
||||
const trace = this.tracer.startTrace(`${this.name}-search`, { trigger });
|
||||
const ctx: RunContext = { trace, logger: this.logger };
|
||||
try {
|
||||
let library: Movie[] = [];
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Langfuse } from 'langfuse';
|
||||
import { LangfuseClient } from '@langfuse/client';
|
||||
import type { PromptFetcher, RawPrompt } from './PromptGateway';
|
||||
|
||||
export class LangfusePromptGateway implements PromptFetcher {
|
||||
private readonly client: Langfuse;
|
||||
private readonly client: LangfuseClient;
|
||||
|
||||
constructor(opts: { publicKey: string; secretKey: string; baseUrl: string }) {
|
||||
this.client = new Langfuse({
|
||||
this.client = new LangfuseClient({
|
||||
publicKey: opts.publicKey,
|
||||
secretKey: opts.secretKey,
|
||||
baseUrl: opts.baseUrl,
|
||||
@@ -13,7 +13,7 @@ export class LangfusePromptGateway implements PromptFetcher {
|
||||
}
|
||||
|
||||
async fetch(promptName: string): Promise<RawPrompt> {
|
||||
const prompt = await this.client.getPrompt(promptName, undefined, { type: 'chat', cacheTtlSeconds: 0 });
|
||||
const prompt = await this.client.prompt.get(promptName, { type: 'chat' });
|
||||
return {
|
||||
name: prompt.name,
|
||||
version: prompt.version,
|
||||
|
||||
@@ -1,33 +1,81 @@
|
||||
import { Langfuse } from 'langfuse';
|
||||
import { NodeSDK } from '@opentelemetry/sdk-node';
|
||||
import { LangfuseSpanProcessor } from '@langfuse/otel';
|
||||
import { propagateAttributes, startObservation } from '@langfuse/tracing';
|
||||
import type { LangfuseGeneration, LangfuseSpan } from '@langfuse/tracing';
|
||||
import { Tracer } from '../llm/Tracer';
|
||||
import type { GenerationHandle, GenerationInput, GenerationOutput, TraceHandle } from '../llm/Tracer';
|
||||
import type {
|
||||
GenerationHandle,
|
||||
GenerationInput,
|
||||
GenerationOutput,
|
||||
SessionInfo,
|
||||
TraceHandle,
|
||||
} from '../llm/Tracer';
|
||||
|
||||
interface OpenAiUsageDetails {
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
total_tokens?: number;
|
||||
prompt_tokens_details?: { cached_tokens: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Langfuse recognizes usage in either a flat generic schema (input/output/
|
||||
* cache_read_input_tokens/...) or OpenAI's native nested schema
|
||||
* (prompt_tokens/completion_tokens/prompt_tokens_details.cached_tokens/...)
|
||||
* - but NOT a mix of the two ("do not mix formats" per Langfuse's docs).
|
||||
* Mixing them previously caused the whole generation.update() payload
|
||||
* (including output) to land wrong, so this stays entirely within the
|
||||
* OpenAI-native schema.
|
||||
*
|
||||
* The installed @langfuse/tracing SDK's usageDetails type is
|
||||
* `Record<string, number> | OpenAiUsage` (a 3-field camelCase shape with
|
||||
* no _details nesting) even though the wire schema (@langfuse/core's
|
||||
* OpenAiCompletionUsageSchema) supports this nested form - a gap in the
|
||||
* SDK's public types, not a real runtime restriction, hence the cast.
|
||||
*/
|
||||
function toUsageDetails(usage: GenerationOutput['usage']): OpenAiUsageDetails | undefined {
|
||||
if (!usage) return undefined;
|
||||
const details: OpenAiUsageDetails = {};
|
||||
if (usage.promptTokens !== undefined) details.prompt_tokens = usage.promptTokens;
|
||||
if (usage.completionTokens !== undefined) details.completion_tokens = usage.completionTokens;
|
||||
if (usage.totalTokens !== undefined) details.total_tokens = usage.totalTokens;
|
||||
if (usage.cachedTokens !== undefined) details.prompt_tokens_details = { cached_tokens: usage.cachedTokens };
|
||||
return Object.keys(details).length > 0 ? details : undefined;
|
||||
}
|
||||
|
||||
export class LangfuseTracer extends Tracer {
|
||||
private readonly client: Langfuse;
|
||||
private readonly processor: LangfuseSpanProcessor;
|
||||
private readonly sdk: NodeSDK;
|
||||
|
||||
constructor(opts: { publicKey: string; secretKey: string; baseUrl: string }) {
|
||||
super();
|
||||
this.client = new Langfuse({
|
||||
this.processor = new LangfuseSpanProcessor({
|
||||
publicKey: opts.publicKey,
|
||||
secretKey: opts.secretKey,
|
||||
baseUrl: opts.baseUrl,
|
||||
});
|
||||
this.sdk = new NodeSDK({ spanProcessors: [this.processor] });
|
||||
this.sdk.start();
|
||||
}
|
||||
|
||||
startTrace(name: string, metadata?: Record<string, unknown>): TraceHandle {
|
||||
const trace = this.client.trace({ name, metadata });
|
||||
const span: LangfuseSpan = startObservation(name, { metadata });
|
||||
return {
|
||||
id: trace.id,
|
||||
id: span.traceId,
|
||||
startGeneration(input: GenerationInput): GenerationHandle {
|
||||
const generation = trace.generation({
|
||||
name: input.name,
|
||||
model: input.model,
|
||||
input: input.input,
|
||||
metadata: input.metadata,
|
||||
});
|
||||
const generation: LangfuseGeneration = span.startObservation(
|
||||
input.name,
|
||||
{ model: input.model, input: input.input, metadata: input.metadata },
|
||||
{ asType: 'generation' }
|
||||
);
|
||||
return {
|
||||
end(output: GenerationOutput) {
|
||||
generation.end({ output: output.output, usage: output.usage, metadata: output.metadata });
|
||||
generation.update({
|
||||
output: output.output,
|
||||
usageDetails: toUsageDetails(output.usage) as Record<string, number> | undefined,
|
||||
metadata: output.metadata,
|
||||
});
|
||||
generation.end();
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -35,6 +83,14 @@ export class LangfuseTracer extends Tracer {
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
await this.client.flushAsync();
|
||||
await this.processor.forceFlush();
|
||||
}
|
||||
|
||||
runWithSession<T>(session: SessionInfo, fn: () => Promise<T>): Promise<T> {
|
||||
return propagateAttributes({ sessionId: session.sessionId, tags: session.tags }, fn);
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
await this.sdk.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,12 @@ export interface ChatOptions {
|
||||
|
||||
export interface ChatResult {
|
||||
content: string;
|
||||
usage?: { promptTokens?: number; completionTokens?: number; totalTokens?: number };
|
||||
usage?: {
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
totalTokens?: number;
|
||||
cachedTokens?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export class LlmClient {
|
||||
@@ -43,6 +48,7 @@ export class LlmClient {
|
||||
promptTokens: response.usage.prompt_tokens,
|
||||
completionTokens: response.usage.completion_tokens,
|
||||
totalTokens: response.usage.total_tokens,
|
||||
cachedTokens: response.usage.prompt_tokens_details?.cached_tokens,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Logger } from '../logging/logger';
|
||||
import type { LlmTool } from './LlmTool';
|
||||
import type { RunContext } from './RunContext';
|
||||
import type { Tracer } from './Tracer';
|
||||
|
||||
export interface PipelineRunOptions {
|
||||
/** What triggered this run (e.g. "cron", "api-list") — attached as a Langfuse tag. */
|
||||
trigger?: string;
|
||||
}
|
||||
|
||||
export abstract class LlmPipeline<TInput, TOutput> {
|
||||
constructor(
|
||||
protected readonly tracer: Tracer,
|
||||
@@ -12,14 +18,20 @@ export abstract class LlmPipeline<TInput, TOutput> {
|
||||
protected abstract readonly name: string;
|
||||
protected abstract runInternal(input: TInput, ctx: RunContext): Promise<TOutput>;
|
||||
|
||||
async runAsync(input: TInput): Promise<TOutput> {
|
||||
const trace = this.tracer.startTrace(this.name);
|
||||
const ctx: RunContext = { trace, logger: this.logger };
|
||||
try {
|
||||
return await this.runInternal(input, ctx);
|
||||
} finally {
|
||||
await this.tracer.flush();
|
||||
}
|
||||
async runAsync(input: TInput, options: PipelineRunOptions = {}): Promise<TOutput> {
|
||||
const sessionId = randomUUID();
|
||||
return this.tracer.runWithSession(
|
||||
{ sessionId, tags: options.trigger ? [options.trigger] : undefined },
|
||||
async () => {
|
||||
const trace = this.tracer.startTrace(this.name, { trigger: options.trigger });
|
||||
const ctx: RunContext = { trace, logger: this.logger };
|
||||
try {
|
||||
return await this.runInternal(input, ctx);
|
||||
} finally {
|
||||
await this.tracer.flush();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
protected runTool<TToolInput, TToolOutput>(
|
||||
|
||||
@@ -7,7 +7,12 @@ export interface GenerationInput {
|
||||
|
||||
export interface GenerationOutput {
|
||||
output: unknown;
|
||||
usage?: { promptTokens?: number; completionTokens?: number; totalTokens?: number };
|
||||
usage?: {
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
totalTokens?: number;
|
||||
cachedTokens?: number;
|
||||
};
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -20,7 +25,18 @@ export interface TraceHandle {
|
||||
startGeneration(input: GenerationInput): GenerationHandle;
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
sessionId: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export abstract class Tracer {
|
||||
abstract startTrace(name: string, metadata?: Record<string, unknown>): TraceHandle;
|
||||
abstract flush(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Runs `fn` with `session` (sessionId/tags) attached to every trace/span
|
||||
* created inside it, so multiple runs can be grouped in the Langfuse UI.
|
||||
*/
|
||||
abstract runWithSession<T>(session: SessionInfo, fn: () => Promise<T>): Promise<T>;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ describe('RecommendationsController', () => {
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: '/recommendations' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(runAsync).toHaveBeenCalledWith({ count: 10, useLlm: true });
|
||||
expect(runAsync).toHaveBeenCalledWith({ count: 10, useLlm: true }, { trigger: 'api-list' });
|
||||
});
|
||||
|
||||
it('GET /recommendations?count= and force_llm/force_tmdb override config', async () => {
|
||||
@@ -25,7 +25,7 @@ describe('RecommendationsController', () => {
|
||||
const app = makeApp({ RECOMMEND_COUNT: 10, LLM_ENABLED: false }, { runAsync } as unknown as RecommendationPipeline);
|
||||
|
||||
await app.inject({ method: 'GET', url: '/recommendations?count=3&force_llm=true' });
|
||||
expect(runAsync).toHaveBeenCalledWith({ count: 3, useLlm: true });
|
||||
expect(runAsync).toHaveBeenCalledWith({ count: 3, useLlm: true }, { trigger: 'api-list' });
|
||||
});
|
||||
|
||||
it('falls back to the configured count for a malformed or non-positive count query param', async () => {
|
||||
@@ -33,13 +33,13 @@ describe('RecommendationsController', () => {
|
||||
const app = makeApp({ RECOMMEND_COUNT: 10, LLM_ENABLED: true }, { runAsync } as unknown as RecommendationPipeline);
|
||||
|
||||
await app.inject({ method: 'GET', url: '/recommendations?count=abc' });
|
||||
expect(runAsync).toHaveBeenCalledWith({ count: 10, useLlm: true });
|
||||
expect(runAsync).toHaveBeenCalledWith({ count: 10, useLlm: true }, { trigger: 'api-list' });
|
||||
|
||||
await app.inject({ method: 'GET', url: '/recommendations?count=-5' });
|
||||
expect(runAsync).toHaveBeenCalledWith({ count: 10, useLlm: true });
|
||||
expect(runAsync).toHaveBeenCalledWith({ count: 10, useLlm: true }, { trigger: 'api-list' });
|
||||
|
||||
await app.inject({ method: 'GET', url: '/recommendations?count=0' });
|
||||
expect(runAsync).toHaveBeenCalledWith({ count: 10, useLlm: true });
|
||||
expect(runAsync).toHaveBeenCalledWith({ count: 10, useLlm: true }, { trigger: 'api-list' });
|
||||
});
|
||||
|
||||
it('POST /recommendations/trigger auto-requests via Radarr when enabled', async () => {
|
||||
@@ -75,6 +75,6 @@ describe('RecommendationsController', () => {
|
||||
payload: { query: 'sci-fi like Blade Runner' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(searchAsync).toHaveBeenCalledWith('sci-fi like Blade Runner', 10);
|
||||
expect(searchAsync).toHaveBeenCalledWith('sci-fi like Blade Runner', 10, 'api-search');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ function makeTracer() {
|
||||
const tracer = {
|
||||
startTrace: vi.fn().mockReturnValue(trace),
|
||||
flush: vi.fn().mockResolvedValue(undefined),
|
||||
runWithSession: vi.fn().mockImplementation((_session: unknown, fn: () => unknown) => fn()),
|
||||
} as unknown as Tracer;
|
||||
return tracer;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const compile = vi.fn().mockReturnValue([{ role: 'user', content: 'hi' }]);
|
||||
const getPrompt = vi.fn().mockResolvedValue({ name: 'greet', version: 2, config: { model: 'llama3' }, compile });
|
||||
const promptGet = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ name: 'greet', version: 2, config: { model: 'llama3' }, compile });
|
||||
|
||||
vi.mock('langfuse', () => ({
|
||||
Langfuse: vi.fn().mockImplementation(() => ({ getPrompt })),
|
||||
vi.mock('@langfuse/client', () => ({
|
||||
LangfuseClient: vi.fn().mockImplementation(() => ({ prompt: { get: promptGet } })),
|
||||
}));
|
||||
|
||||
import { LangfusePromptGateway } from '../../../src/core/langfuse/LangfusePromptGateway';
|
||||
|
||||
describe('LangfusePromptGateway', () => {
|
||||
it('fetches a chat prompt from Langfuse bypassing the SDK cache, and adapts it to RawPrompt', async () => {
|
||||
it('fetches a chat prompt from Langfuse and adapts it to RawPrompt', async () => {
|
||||
const gateway = new LangfusePromptGateway({ publicKey: 'pk', secretKey: 'sk', baseUrl: 'http://lf' });
|
||||
const raw = await gateway.fetch('greet');
|
||||
|
||||
expect(getPrompt).toHaveBeenCalledWith('greet', undefined, { type: 'chat', cacheTtlSeconds: 0 });
|
||||
expect(promptGet).toHaveBeenCalledWith('greet', { type: 'chat' });
|
||||
expect(raw.name).toBe('greet');
|
||||
expect(raw.version).toBe(2);
|
||||
expect(raw.config).toEqual({ model: 'llama3' });
|
||||
|
||||
@@ -1,42 +1,133 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const generationEnd = vi.fn();
|
||||
const generation = vi.fn().mockReturnValue({ end: generationEnd });
|
||||
const trace = vi.fn().mockReturnValue({ id: 'lf-trace-1', generation });
|
||||
const flushAsync = vi.fn().mockResolvedValue(undefined);
|
||||
const {
|
||||
forceFlush,
|
||||
shutdown,
|
||||
sdkStart,
|
||||
sdkShutdown,
|
||||
generationUpdate,
|
||||
generationEnd,
|
||||
childStartObservation,
|
||||
startObservation,
|
||||
propagateAttributes,
|
||||
} = vi.hoisted(() => {
|
||||
const generationUpdate = vi.fn();
|
||||
const generationEnd = vi.fn();
|
||||
const generation = { update: generationUpdate, end: generationEnd };
|
||||
const childStartObservation = vi.fn().mockReturnValue(generation);
|
||||
const rootSpan = { traceId: 'lf-trace-1', startObservation: childStartObservation };
|
||||
return {
|
||||
forceFlush: vi.fn().mockResolvedValue(undefined),
|
||||
shutdown: vi.fn().mockResolvedValue(undefined),
|
||||
sdkStart: vi.fn(),
|
||||
sdkShutdown: vi.fn().mockResolvedValue(undefined),
|
||||
generationUpdate,
|
||||
generationEnd,
|
||||
childStartObservation,
|
||||
startObservation: vi.fn().mockReturnValue(rootSpan),
|
||||
propagateAttributes: vi.fn().mockImplementation((_attrs: unknown, fn: () => unknown) => fn()),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('langfuse', () => ({
|
||||
Langfuse: vi.fn().mockImplementation(() => ({ trace, flushAsync })),
|
||||
vi.mock('@langfuse/otel', () => ({
|
||||
LangfuseSpanProcessor: vi.fn().mockImplementation(() => ({ forceFlush, shutdown })),
|
||||
}));
|
||||
|
||||
vi.mock('@opentelemetry/sdk-node', () => ({
|
||||
NodeSDK: vi.fn().mockImplementation(() => ({ start: sdkStart, shutdown: sdkShutdown })),
|
||||
}));
|
||||
|
||||
vi.mock('@langfuse/tracing', () => ({ startObservation, propagateAttributes }));
|
||||
|
||||
import { LangfuseTracer } from '../../../src/core/langfuse/LangfuseTracer';
|
||||
|
||||
describe('LangfuseTracer', () => {
|
||||
it('starts a Langfuse trace and exposes its id', () => {
|
||||
it('starts the OTEL SDK with a LangfuseSpanProcessor on construction', () => {
|
||||
new LangfuseTracer({ publicKey: 'pk', secretKey: 'sk', baseUrl: 'http://lf' });
|
||||
expect(sdkStart).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts a root observation and exposes its trace id', () => {
|
||||
const tracer = new LangfuseTracer({ publicKey: 'pk', secretKey: 'sk', baseUrl: 'http://lf' });
|
||||
const handle = tracer.startTrace('my-pipeline', { foo: 'bar' });
|
||||
|
||||
expect(trace).toHaveBeenCalledWith({ name: 'my-pipeline', metadata: { foo: 'bar' } });
|
||||
expect(startObservation).toHaveBeenCalledWith('my-pipeline', { metadata: { foo: 'bar' } });
|
||||
expect(handle.id).toBe('lf-trace-1');
|
||||
});
|
||||
|
||||
it('startGeneration creates a Langfuse generation and end() forwards output/usage', () => {
|
||||
it('startGeneration creates a child generation observation and end() forwards output/usage', () => {
|
||||
const tracer = new LangfuseTracer({ publicKey: 'pk', secretKey: 'sk', baseUrl: 'http://lf' });
|
||||
const handle = tracer.startTrace('my-pipeline');
|
||||
const gen = handle.startGeneration({ name: 'step-1', model: 'llama3', input: ['hi'] });
|
||||
gen.end({ output: 'done', usage: { totalTokens: 5 } });
|
||||
gen.end({
|
||||
output: 'done',
|
||||
usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 },
|
||||
});
|
||||
|
||||
expect(generation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'step-1', model: 'llama3', input: ['hi'] })
|
||||
expect(childStartObservation).toHaveBeenCalledWith(
|
||||
'step-1',
|
||||
expect.objectContaining({ model: 'llama3', input: ['hi'] }),
|
||||
{ asType: 'generation' }
|
||||
);
|
||||
expect(generationEnd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ output: 'done', usage: { totalTokens: 5 } })
|
||||
expect(generationUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
output: 'done',
|
||||
usageDetails: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
||||
})
|
||||
);
|
||||
expect(generationEnd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('maps cachedTokens to the OpenAI-native nested prompt_tokens_details.cached_tokens, not a mixed flat key', () => {
|
||||
const tracer = new LangfuseTracer({ publicKey: 'pk', secretKey: 'sk', baseUrl: 'http://lf' });
|
||||
const handle = tracer.startTrace('my-pipeline');
|
||||
const gen = handle.startGeneration({ name: 'step-1', model: 'gpt-4o-mini', input: ['hi'] });
|
||||
gen.end({
|
||||
output: 'done',
|
||||
usage: { promptTokens: 100, completionTokens: 20, totalTokens: 120, cachedTokens: 80 },
|
||||
});
|
||||
|
||||
expect(generationUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
usageDetails: {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 20,
|
||||
total_tokens: 120,
|
||||
prompt_tokens_details: { cached_tokens: 80 },
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('flush() calls flushAsync on the underlying client', async () => {
|
||||
it('omits usageDetails entirely when no usage is reported', () => {
|
||||
const tracer = new LangfuseTracer({ publicKey: 'pk', secretKey: 'sk', baseUrl: 'http://lf' });
|
||||
const handle = tracer.startTrace('my-pipeline');
|
||||
const gen = handle.startGeneration({ name: 'step-1', model: 'llama3', input: ['hi'] });
|
||||
gen.end({ output: 'done' });
|
||||
|
||||
expect(generationUpdate).toHaveBeenCalledWith(expect.objectContaining({ usageDetails: undefined }));
|
||||
});
|
||||
|
||||
it('flush() force-flushes the span processor without shutting down the SDK', async () => {
|
||||
const tracer = new LangfuseTracer({ publicKey: 'pk', secretKey: 'sk', baseUrl: 'http://lf' });
|
||||
await tracer.flush();
|
||||
expect(flushAsync).toHaveBeenCalledTimes(1);
|
||||
expect(forceFlush).toHaveBeenCalledTimes(1);
|
||||
expect(sdkShutdown).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shutdown() shuts down the underlying OTEL SDK', async () => {
|
||||
const tracer = new LangfuseTracer({ publicKey: 'pk', secretKey: 'sk', baseUrl: 'http://lf' });
|
||||
await tracer.shutdown();
|
||||
expect(sdkShutdown).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('runWithSession propagates sessionId/tags to the callback via propagateAttributes and returns its result', async () => {
|
||||
const tracer = new LangfuseTracer({ publicKey: 'pk', secretKey: 'sk', baseUrl: 'http://lf' });
|
||||
const fn = vi.fn().mockResolvedValue('done');
|
||||
|
||||
const result = await tracer.runWithSession({ sessionId: 'session-1', tags: ['cron'] }, fn);
|
||||
|
||||
expect(propagateAttributes).toHaveBeenCalledWith({ sessionId: 'session-1', tags: ['cron'] }, fn);
|
||||
expect(result).toBe('done');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,28 @@ describe('LlmClient', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts cachedTokens from prompt_tokens_details when present', async () => {
|
||||
createMock.mockResolvedValue({
|
||||
choices: [{ message: { content: 'hello' } }],
|
||||
usage: {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 20,
|
||||
total_tokens: 120,
|
||||
prompt_tokens_details: { cached_tokens: 80 },
|
||||
},
|
||||
});
|
||||
|
||||
const client = new LlmClient({ baseUrl: 'http://localhost:11434/v1' });
|
||||
const result = await client.chat([{ role: 'user', content: 'hi' }], { model: 'gpt-4o-mini' });
|
||||
|
||||
expect(result.usage).toEqual({
|
||||
promptTokens: 100,
|
||||
completionTokens: 20,
|
||||
totalTokens: 120,
|
||||
cachedTokens: 80,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when the response has no choices', async () => {
|
||||
createMock.mockResolvedValue({ choices: [] });
|
||||
|
||||
|
||||
@@ -23,24 +23,44 @@ class ThrowingPipeline extends LlmPipeline<void, void> {
|
||||
function makeTracer(trace: TraceHandle) {
|
||||
const flush = vi.fn().mockResolvedValue(undefined);
|
||||
const startTrace = vi.fn().mockReturnValue(trace);
|
||||
const tracer = { startTrace, flush } as unknown as Tracer;
|
||||
return { tracer, startTrace, flush };
|
||||
const runWithSession = vi.fn().mockImplementation((_session: unknown, fn: () => unknown) => fn());
|
||||
const tracer = { startTrace, flush, runWithSession } as unknown as Tracer;
|
||||
return { tracer, startTrace, flush, runWithSession };
|
||||
}
|
||||
|
||||
describe('LlmPipeline', () => {
|
||||
it('opens one trace, runs runInternal with it, and flushes afterward', async () => {
|
||||
it('runs the whole call inside a session scoped to a fresh session id, opens one trace, runs runInternal, and flushes afterward', async () => {
|
||||
const trace = { id: 'trace-1', startGeneration: vi.fn() } as unknown as TraceHandle;
|
||||
const { tracer, startTrace, flush } = makeTracer(trace);
|
||||
const { tracer, startTrace, flush, runWithSession } = makeTracer(trace);
|
||||
const logger = {} as unknown as Logger;
|
||||
|
||||
const pipeline = new SumPipeline(tracer, logger);
|
||||
const result = await pipeline.runAsync([1, 2, 3]);
|
||||
|
||||
expect(result).toBe(6);
|
||||
expect(startTrace).toHaveBeenCalledWith('sum-pipeline');
|
||||
expect(runWithSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionId: expect.any(String), tags: undefined }),
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(startTrace).toHaveBeenCalledWith('sum-pipeline', { trigger: undefined });
|
||||
expect(flush).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('tags the session with the given trigger', async () => {
|
||||
const trace = { id: 'trace-1', startGeneration: vi.fn() } as unknown as TraceHandle;
|
||||
const { tracer, startTrace, runWithSession } = makeTracer(trace);
|
||||
const logger = {} as unknown as Logger;
|
||||
|
||||
const pipeline = new SumPipeline(tracer, logger);
|
||||
await pipeline.runAsync([1, 2, 3], { trigger: 'cron' });
|
||||
|
||||
expect(runWithSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tags: ['cron'] }),
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(startTrace).toHaveBeenCalledWith('sum-pipeline', { trigger: 'cron' });
|
||||
});
|
||||
|
||||
it('still flushes the trace when runInternal throws', async () => {
|
||||
const trace = { id: 'trace-2', startGeneration: vi.fn() } as unknown as TraceHandle;
|
||||
const { tracer, flush } = makeTracer(trace);
|
||||
|
||||
Reference in New Issue
Block a user