13 Commits
0.1.0 ... main

Author SHA1 Message Date
2c82a8416f Document OpenRouter as a supported LLM_BASE_URL provider 2026-08-12 23:43:51 +02:00
cb5e7085bf Support OpenRouter's real per-request cost reporting
Send usage: { include: true } on every chat completion request (an
OpenRouter-specific extension - requires Usage Accounting enabled on
the OpenRouter account; other OpenAI-compatible providers generally
ignore unknown top-level request fields). When the response includes
OpenRouter's own usage.cost, forward it to Langfuse as
costDetails.total, so cost is the real per-request figure OpenRouter
billed rather than a guess from Langfuse's model price list (which
has no entry for proxied/aggregated OpenRouter model names anyway).

Verified: full suite (84/84) passing, tsc/eslint clean, docker
compose build succeeds. Per Langfuse's OpenRouter integration guide:
https://langfuse.com/integrations/gateways/openrouter
2026-08-12 23:43:30 +02:00
9a4d254968 Fix input/output getting misrouted by mixed usage-schema keys
The previous cached-tokens commit added cache_read_input_tokens (a
flat-schema key) alongside prompt_tokens/completion_tokens (OpenAI's
native schema) in the same usageDetails object. Langfuse's ingestion
explicitly does not support mixing those two schemas, and doing so
broke generation.update()'s output field - confirmed live: prompt/
response content was landing in the Usage panel instead of Input/
Output.

Fixed by staying entirely within OpenAI's native schema: cached
tokens now nest under prompt_tokens_details.cached_tokens instead of
using the flat cache_read_input_tokens key. Cast past the installed
@langfuse/tracing SDK's usageDetails type (which only exposes a
3-field camelCase shape with no _details nesting, unlike the wire
schema in @langfuse/core) since the nesting is valid on the wire even
though the SDK's public type doesn't model it.

Verified: full suite (82/82) passing, tsc/eslint clean.
2026-08-12 23:24:09 +02:00
df4ca0bf76 Track cached prompt tokens through to Langfuse
OpenAI's chat completions response includes prompt_tokens_details.
cached_tokens when prompt caching kicks in - previously dropped
entirely. Now extracted in LlmClient and forwarded to Langfuse as the
flat cache_read_input_tokens usage key.

Used the flat key rather than OpenAI's nested prompt_tokens_details
shape: the installed @langfuse/tracing SDK's usageDetails type is
Record<string, number> | OpenAiUsage (a 3-field camelCase shape, no
_details nesting) even though the wire schema in @langfuse/core
supports nesting - a gap in the SDK's public types, not something
worth fighting with a cast.

Verified: full suite (82/82) passing, tsc/eslint clean.
2026-08-12 23:15:42 +02:00
6f0300f038 Fix Langfuse usage tracking: map to snake_case keys it recognizes
LangfuseTracer was passing our internal camelCase usage fields
(promptTokens/completionTokens/totalTokens) straight through as
usageDetails. Langfuse only recognizes a flat schema (input/output/...)
or OpenAI's native snake_case schema (prompt_tokens/completion_tokens/
total_tokens) for usage/cost calculation - anything else is silently
ignored, so every generation was showing zero usage regardless of
what LlmClient actually reported.

Note: cost still requires a price-list entry for the model name.
Langfuse's built-in list only covers hosted providers (OpenAI,
Anthropic, Google); self-hosted models (e.g. LLM_MODEL=llama3 via
Ollama) need a custom model price added in Project Settings > Models
for a $ figure to show up, even with token counts now correct.

Verified: full suite (80/80) passing, tsc/eslint clean.
2026-08-12 23:00:04 +02:00
e47c57b50d Group pipeline runs into Langfuse sessions, tagged by trigger source
Each pipeline invocation (runAsync/searchAsync) now gets a fresh
sessionId, wrapped via the new Tracer.runWithSession() (implemented
with @langfuse/tracing's propagateAttributes, since v5 has no direct
sessionId/tags option on startObservation) so it shows up in
Langfuse's Sessions view. The session is also tagged with what
triggered the run: "cron", "api-list", "api-trigger", "api-search".

Verified: full test suite (79/79) passing, tsc/eslint clean, real
docker compose build + container smoke test against /health.
2026-08-12 22:51:17 +02:00
b615f08039 Copy pnpm-workspace.yaml into Docker build so protobufjs build-script
approval (needed by the new OTEL-based Langfuse SDK) actually takes
effect inside the image, not just locally.

Verified with docker compose build --no-cache + a real container run
against /health.
2026-08-12 22:21:15 +02:00
a5120d5153 upgrade langfuse 2026-08-12 22:17:56 +02:00
2ffba19392 add langfuse url to server starting 2026-08-12 22:03:04 +02:00
a537f22384 fix network 2026-08-12 21:52:45 +02:00
a90f9dc19b add coolify network 2026-08-12 21:46:32 +02:00
83bb16e2a0 Remove Go implementation, promote TypeScript app as canonical
The TypeScript/OOP rewrite has reached full functional parity and is
now the only implementation:

- Delete cmd/, internal/, go.mod, go.sum.
- Promote Dockerfile.node -> Dockerfile and docker-compose.node.yml ->
  docker-compose.yml (service renamed sugarr-node -> sugarr).
- Update README and .gitignore to drop the "two implementations"
  framing and Go-specific entries.

Verified after the rename: tsc --noEmit clean, full test suite
passing (75/75), docker compose build succeeds.
2026-08-12 21:45:32 +02:00
ae8bfaa2eb Fix final review findings: key leak, model fallback, error resilience, docs
- ApiClient: strip query string from ApiError messages so API keys
  (e.g. TMDb's api_key query param) never leak into 500 response
  bodies or logs.
- LlmTool: accept an optional defaultModel, wired from AppConfig's
  LLM_MODEL, so Langfuse prompts that omit a model config fall back
  to the configured LLM rather than silently requesting gpt-4o-mini.
- RecommendationPipeline: restore the Go original's graceful
  degradation — library/Radarr fetch failures, per-suggestion TMDb
  resolution failures, and per-signal TMDb similarity/recommendation
  failures are now logged and skipped instead of aborting the whole
  run (applies to both runInternal and searchAsync).
- RecommendationsController: validate the ?count= query param
  (must be a positive integer) instead of silently producing NaN.
- Commit the Langfuse prompt definitions (langfuse/*.prompt.json) and
  add a README covering setup, prompt seeding, cache invalidation,
  and the HTTP API surface — the branch was not otherwise runnable by
  anyone but the author.
2026-08-12 21:40:09 +02:00
47 changed files with 1498 additions and 1504 deletions

View File

@@ -4,7 +4,12 @@ JELLYFIN_API_KEY=
JELLYFIN_USER_ID= JELLYFIN_USER_ID=
TMDB_API_KEY= TMDB_API_KEY=
# LLM (disabled by default — works with Ollama, OpenAI, LM Studio, etc.) # LLM (disabled by default — any OpenAI-compatible endpoint: Ollama, OpenAI,
# LM Studio, OpenRouter, etc.)
# For OpenRouter specifically: LLM_BASE_URL=https://openrouter.ai/api/v1,
# LLM_API_KEY=<your OpenRouter key>, LLM_MODEL=<an OpenRouter model slug>.
# Enable "Usage Accounting" on the OpenRouter account to get real per-request
# cost reported back to Langfuse instead of a price-list estimate.
LLM_ENABLED=false LLM_ENABLED=false
LLM_BASE_URL=http://localhost:11434/v1 LLM_BASE_URL=http://localhost:11434/v1
LLM_API_KEY= LLM_API_KEY=

35
.gitignore vendored
View File

@@ -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 .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 .zed
deploy.config deploy.config
@@ -41,3 +7,4 @@ deploy.config
node_modules/ node_modules/
dist/ dist/
coverage/ coverage/
*.log

View File

@@ -1,11 +1,17 @@
FROM golang:1.24-alpine AS build FROM node:22-alpine AS build
WORKDIR /app WORKDIR /app
COPY go.mod go.sum ./ RUN corepack enable
RUN go mod download COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY . . RUN pnpm install --frozen-lockfile --ignore-scripts
RUN CGO_ENABLED=0 GOOS=linux go build -o /sugarr ./cmd/sugarr COPY tsconfig.json ./
COPY src ./src
RUN pnpm build
FROM alpine:3.19 FROM node:22-alpine AS runtime
RUN apk add --no-cache ca-certificates WORKDIR /app
COPY --from=build /sugarr /usr/local/bin/ ENV NODE_ENV=production
ENTRYPOINT ["sugarr"] 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"]

View File

@@ -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"]

92
README.md Normal file
View File

@@ -0,0 +1,92 @@
# sugarr
Movie-recommendation companion for Jellyfin: pulls your favorite/recently-watched
movies, gets suggestions from an LLM (via Langfuse-managed prompts) or a TMDb
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.
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).
## Running the app
```bash
pnpm install
cp .env.example .env # fill in the values below
pnpm dev # or: pnpm build && pnpm start
```
### Required environment variables
See `.env.example` for the full list with defaults. At minimum you need:
- `JELLYFIN_URL`, `JELLYFIN_API_KEY`, `JELLYFIN_USER_ID`, `TMDB_API_KEY`
- `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY`, `LANGFUSE_BASE_URL` — the app
fetches its LLM prompts from Langfuse at runtime (see below), so these are
required even if `LLM_ENABLED=false` (only the recommend/search endpoints
actually call Langfuse; if you never enable the LLM path you can point
these at any reachable Langfuse instance).
### Docker
```bash
docker compose up -d --build
```
## Seeding Langfuse prompts
The app fetches two chat prompts from Langfuse by name — `movie-recommender`
and `movie-search` — compiles them with per-request variables, and reads
model/temperature/etc. from each prompt version's `config` field. Ready-to-import
definitions are in `langfuse/`:
- `langfuse/movie-recommender.prompt.json`
- `langfuse/movie-search.prompt.json`
Create each one in your Langfuse project (UI: **Prompts → New prompt**, type
**Chat**; or via the Langfuse API/SDK) using the `prompt` array as the chat
messages and the `config` object as the prompt's config. Variables use
`{{name}}` interpolation:
- `movie-recommender`: `count`, `favorites`, `watched`, `library`, `previouslySuggested`
- `movie-search`: `query`, `count`
## Prompt caching and invalidation
Compiled prompts are cached in memory per process (`LANGFUSE_PROMPT_CACHE_ENABLED`,
default `true`; set to `false` to always fetch fresh from Langfuse — useful
while iterating on a prompt). To invalidate the cache after updating a prompt
in Langfuse without restarting the app, call:
```
POST /webhooks/langfuse/invalidate
Content-Type: application/json
Authorization: Bearer <SECRET_KEY> # if SECRET_KEY is set
{"promptName": "movie-recommender"} # or {} / omit body to clear the whole cache
```
This isn't wired to a Langfuse-native push webhook (self-hosted Langfuse has
no built-in "prompt updated" event) — call it manually, from CI after a prompt
change, or from Langfuse Cloud's webhook feature if you have one configured.
## HTTP API
| Method | Path | Notes |
|---|---|---|
| GET | `/health` | exempt from auth |
| GET | `/recommendations` | `?count=`, `?force_llm=true`, `?force_tmdb=true` |
| POST | `/recommendations/search` | body `{"query": "..."}`, requires `LLM_ENABLED=true` |
| POST | `/recommendations/trigger` | runs the pipeline + auto-requests via Radarr if enabled |
| POST | `/recommendations/request` | body `{"tmdb_id": 12345}`, requires `RADARR_ENABLED=true` |
| POST | `/webhooks/langfuse/invalidate` | body `{"promptName"?: string}` |
If `SECRET_KEY` is set, all routes except `/health` require
`Authorization: Bearer <SECRET_KEY>`.

View File

@@ -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()
}

View File

@@ -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:

View File

@@ -1,10 +1,12 @@
services: services:
sugarr: sugarr:
build: . build:
context: .
dockerfile: Dockerfile
container_name: sugarr container_name: sugarr
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${API_PORT}:${API_PORT}" - '${API_PORT}:${API_PORT}'
environment: environment:
JELLYFIN_URL: ${JELLYFIN_URL} JELLYFIN_URL: ${JELLYFIN_URL}
JELLYFIN_API_KEY: ${JELLYFIN_API_KEY} JELLYFIN_API_KEY: ${JELLYFIN_API_KEY}
@@ -23,13 +25,17 @@ services:
CRON_SCHEDULE: ${CRON_SCHEDULE} CRON_SCHEDULE: ${CRON_SCHEDULE}
API_ENABLED: ${API_ENABLED} API_ENABLED: ${API_ENABLED}
API_PORT: ${API_PORT} API_PORT: ${API_PORT}
RECOMMEND_COUNT: ${RECOMMEND_COUNT}
MIN_RATED_MOVIES: ${MIN_RATED_MOVIES}
SECRET_KEY: ${SECRET_KEY} SECRET_KEY: ${SECRET_KEY}
RECOMMEND_COUNT: ${RECOMMEND_COUNT}
RECOMMEND_MAX_RETRY: ${RECOMMEND_MAX_RETRY} 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: networks:
- media - coolify
networks: networks:
media: coolify:
external: true

16
go.mod
View File

@@ -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
View File

@@ -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=

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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))
}

View File

@@ -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,
})
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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"`
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
})
}

View File

@@ -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))
}

View File

@@ -0,0 +1,20 @@
{
"name": "movie-recommender",
"type": "chat",
"prompt": [
{
"role": "system",
"content": "You are a movie recommendation engine. Based on the user's favorite and recently watched movies below, suggest exactly {{count}} movies they would enjoy.\n\nIMPORTANT:\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\"}"
},
{
"role": "user",
"content": "User's favorite movies:\n{{favorites}}\n\nUser's recently watched movies:\n{{watched}}\n\nAlready in library (DO NOT suggest these):\n{{library}}\n\nAlready suggested (DO NOT repeat these):\n{{previouslySuggested}}"
}
],
"config": {
"model": "llama3",
"temperature": 0.7,
"maxTokens": 1024
},
"labels": ["production"]
}

View File

@@ -0,0 +1,20 @@
{
"name": "movie-search",
"type": "chat",
"prompt": [
{
"role": "system",
"content": "You are a movie recommendation engine. Return ONLY a JSON array of {{count}} movies matching the user's request. No other text.\nEach entry: {\"title\": \"Movie Title\", \"year\": 2024, \"reason\": \"brief reason\"}"
},
{
"role": "user",
"content": "The user is looking for: {{query}}"
}
],
"config": {
"model": "llama3",
"temperature": 0.7,
"maxTokens": 1024
},
"labels": ["production"]
}

View File

@@ -3,6 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"main": "dist/app/index.js", "main": "dist/app/index.js",
"packageManager": "pnpm@11.21.0",
"scripts": { "scripts": {
"dev": "tsx watch src/app/index.ts", "dev": "tsx watch src/app/index.ts",
"build": "tsc -p tsconfig.json", "build": "tsc -p tsconfig.json",
@@ -13,9 +14,12 @@
"format": "prettier --write ." "format": "prettier --write ."
}, },
"dependencies": { "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", "dotenv": "^16.4.5",
"fastify": "^5.0.0", "fastify": "^5.0.0",
"langfuse": "^3.30.0",
"node-cron": "^3.0.3", "node-cron": "^3.0.3",
"openai": "^4.60.0", "openai": "^4.60.0",
"pino": "^9.4.0", "pino": "^9.4.0",

707
pnpm-lock.yaml generated
View File

@@ -8,15 +8,24 @@ importers:
.: .:
dependencies: 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: dotenv:
specifier: ^16.4.5 specifier: ^16.4.5
version: 16.6.1 version: 16.6.1
fastify: fastify:
specifier: ^5.0.0 specifier: ^5.0.0
version: 5.11.3 version: 5.11.3
langfuse:
specifier: ^3.30.0
version: 3.38.20
node-cron: node-cron:
specifier: ^3.0.3 specifier: ^3.0.3
version: 3.0.3 version: 3.0.3
@@ -393,6 +402,15 @@ packages:
'@fastify/proxy-addr@5.1.0': '@fastify/proxy-addr@5.1.0':
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} 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': '@humanwhocodes/config-array@0.13.0':
resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==}
engines: {node: '>=10.10.0'} engines: {node: '>=10.10.0'}
@@ -409,6 +427,34 @@ packages:
'@jridgewell/sourcemap-codec@1.5.5': '@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} 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': '@napi-rs/lzma-linux-x64-gnu@1.5.1':
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
engines: {node: ^22.20 || ^24.12 || >=25} engines: {node: ^22.20 || ^24.12 || >=25}
@@ -428,9 +474,210 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'} 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': '@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} 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': '@rollup/rollup-android-arm-eabi@4.62.4':
resolution: {integrity: sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==} resolution: {integrity: sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==}
cpu: [arm] cpu: [arm]
@@ -779,6 +1026,13 @@ packages:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'} 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: color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'} engines: {node: '>=7.0.0'}
@@ -843,6 +1097,9 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
end-of-stream@1.4.5: end-of-stream@1.4.5:
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
@@ -857,6 +1114,9 @@ packages:
es-module-lexer@1.7.0: es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} 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: es-object-atoms@1.1.2:
resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -875,6 +1135,10 @@ packages:
engines: {node: '>=18'} engines: {node: '>=18'}
hasBin: true hasBin: true
escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
escape-string-regexp@4.0.0: escape-string-regexp@4.0.0:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -1018,6 +1282,10 @@ packages:
function-bind@1.1.2: function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} 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: get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -1082,6 +1350,10 @@ packages:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
import-in-the-middle@3.3.3:
resolution: {integrity: sha512-AiohS3H80sXO6owEltjGX+glb7qXaDhBoJb9XcQVH4UI207xu/bDLUcadVKp7Qe576reg9yr/PXZjV5qx8gfbA==}
engines: {node: '>=18'}
imurmurhash@0.1.4: imurmurhash@0.1.4:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'} engines: {node: '>=0.8.19'}
@@ -1101,6 +1373,10 @@ packages:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'} 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: is-glob@4.0.3:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -1138,14 +1414,6 @@ packages:
keyv@4.5.4: keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} 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: levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -1157,9 +1425,15 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'} engines: {node: '>=10'}
lodash.camelcase@4.3.0:
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
lodash.merge@4.6.2: lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
long@5.3.2:
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
loupe@3.2.1: loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
@@ -1188,6 +1462,9 @@ packages:
minimist@1.2.8: minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
module-details-from-path@1.0.4:
resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==}
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -1319,6 +1596,10 @@ packages:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'} engines: {node: '>= 0.6.0'}
protobufjs@7.6.5:
resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==}
engines: {node: '>=12.0.0'}
pump@3.0.4: pump@3.0.4:
resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
@@ -1340,10 +1621,18 @@ packages:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'} 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: require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'} 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: resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -1425,6 +1714,10 @@ packages:
std-env@3.10.0: std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} 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: string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
@@ -1598,9 +1891,30 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
wrappy@1.0.2: wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} 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: yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -1803,6 +2117,18 @@ snapshots:
'@fastify/forwarded': 3.0.2 '@fastify/forwarded': 3.0.2
ipaddr.js: 2.5.0 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)': '@humanwhocodes/config-array@0.13.0(supports-color@7.2.0)':
dependencies: dependencies:
'@humanwhocodes/object-schema': 2.0.3 '@humanwhocodes/object-schema': 2.0.3
@@ -1817,6 +2143,32 @@ snapshots:
'@jridgewell/sourcemap-codec@1.5.5': {} '@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': '@napi-rs/lzma-linux-x64-gnu@1.5.1':
optional: true optional: true
@@ -1832,8 +2184,252 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5 '@nodelib/fs.scandir': 2.1.5
fastq: 1.20.1 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': {} '@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': '@rollup/rollup-android-arm-eabi@4.62.4':
optional: true optional: true
@@ -2156,6 +2752,14 @@ snapshots:
check-error@2.1.3: {} 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: color-convert@2.0.1:
dependencies: dependencies:
color-name: 1.1.4 color-name: 1.1.4
@@ -2206,6 +2810,8 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
gopd: 1.2.0 gopd: 1.2.0
emoji-regex@8.0.0: {}
end-of-stream@1.4.5: end-of-stream@1.4.5:
dependencies: dependencies:
once: 1.4.0 once: 1.4.0
@@ -2216,6 +2822,8 @@ snapshots:
es-module-lexer@1.7.0: {} es-module-lexer@1.7.0: {}
es-module-lexer@2.3.1: {}
es-object-atoms@1.1.2: es-object-atoms@1.1.2:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0
@@ -2282,6 +2890,8 @@ snapshots:
'@esbuild/win32-ia32': 0.28.2 '@esbuild/win32-ia32': 0.28.2
'@esbuild/win32-x64': 0.28.2 '@esbuild/win32-x64': 0.28.2
escalade@3.2.0: {}
escape-string-regexp@4.0.0: {} escape-string-regexp@4.0.0: {}
eslint-scope@7.2.2: eslint-scope@7.2.2:
@@ -2464,6 +3074,8 @@ snapshots:
function-bind@1.1.2: {} function-bind@1.1.2: {}
get-caller-file@2.0.5: {}
get-intrinsic@1.3.0: get-intrinsic@1.3.0:
dependencies: dependencies:
call-bind-apply-helpers: 1.0.2 call-bind-apply-helpers: 1.0.2
@@ -2532,6 +3144,12 @@ snapshots:
parent-module: 1.0.1 parent-module: 1.0.1
resolve-from: 4.0.0 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: {} imurmurhash@0.1.4: {}
inflight@1.0.6: inflight@1.0.6:
@@ -2545,6 +3163,8 @@ snapshots:
is-extglob@2.1.1: {} is-extglob@2.1.1: {}
is-fullwidth-code-point@3.0.0: {}
is-glob@4.0.3: is-glob@4.0.3:
dependencies: dependencies:
is-extglob: 2.1.1 is-extglob: 2.1.1
@@ -2575,14 +3195,6 @@ snapshots:
dependencies: dependencies:
json-buffer: 3.0.1 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: levn@0.4.1:
dependencies: dependencies:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
@@ -2598,8 +3210,12 @@ snapshots:
dependencies: dependencies:
p-locate: 5.0.0 p-locate: 5.0.0
lodash.camelcase@4.3.0: {}
lodash.merge@4.6.2: {} lodash.merge@4.6.2: {}
long@5.3.2: {}
loupe@3.2.1: {} loupe@3.2.1: {}
magic-string@0.30.21: magic-string@0.30.21:
@@ -2624,6 +3240,8 @@ snapshots:
minimist@1.2.8: {} minimist@1.2.8: {}
module-details-from-path@1.0.4: {}
ms@2.1.3: {} ms@2.1.3: {}
mustache@4.2.0: {} mustache@4.2.0: {}
@@ -2750,6 +3368,20 @@ snapshots:
process@0.11.10: {} 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: pump@3.0.4:
dependencies: dependencies:
end-of-stream: 1.4.5 end-of-stream: 1.4.5
@@ -2771,8 +3403,17 @@ snapshots:
real-require@0.2.0: {} real-require@0.2.0: {}
require-directory@2.1.1: {}
require-from-string@2.0.2: {} 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: {} resolve-from@4.0.0: {}
ret@0.5.0: {} ret@0.5.0: {}
@@ -2857,6 +3498,12 @@ snapshots:
std-env@3.10.0: {} 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: string_decoder@1.3.0:
dependencies: dependencies:
safe-buffer: 5.2.1 safe-buffer: 5.2.1
@@ -3006,8 +3653,30 @@ snapshots:
word-wrap@1.2.5: {} 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: {} 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: {} yocto-queue@0.1.0: {}
zod@3.25.76: {} zod@3.25.76: {}

2
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
allowBuilds:
protobufjs: true

View File

@@ -30,7 +30,8 @@ export class RecommendationsController extends Controller {
} }
private async handleList(request: FastifyRequest<{ Querystring: ListQuery }>, reply: FastifyReply) { private async handleList(request: FastifyRequest<{ Querystring: ListQuery }>, reply: FastifyReply) {
const count = request.query.count ? Number(request.query.count) : this.config.RECOMMEND_COUNT; const parsedCount = request.query.count ? Number(request.query.count) : NaN;
const count = Number.isInteger(parsedCount) && parsedCount > 0 ? parsedCount : this.config.RECOMMEND_COUNT;
const useLlm = const useLlm =
request.query.force_tmdb === 'true' request.query.force_tmdb === 'true'
? false ? false
@@ -39,7 +40,7 @@ export class RecommendationsController extends Controller {
: this.config.LLM_ENABLED; : this.config.LLM_ENABLED;
try { try {
const recommendations = await this.pipeline.runAsync({ count, useLlm }); const recommendations = await this.pipeline.runAsync({ count, useLlm }, { trigger: 'api-list' });
reply.send(recommendations); reply.send(recommendations);
} catch (err) { } catch (err) {
reply.code(500).send({ error: String(err) }); reply.code(500).send({ error: String(err) });
@@ -48,10 +49,10 @@ export class RecommendationsController extends Controller {
private async handleTrigger(_request: FastifyRequest, reply: FastifyReply) { private async handleTrigger(_request: FastifyRequest, reply: FastifyReply) {
try { try {
const recommendations = await this.pipeline.runAsync({ const recommendations = await this.pipeline.runAsync(
count: this.config.RECOMMEND_COUNT, { count: this.config.RECOMMEND_COUNT, useLlm: this.config.LLM_ENABLED },
useLlm: this.config.LLM_ENABLED, { trigger: 'api-trigger' }
}); );
if (this.config.RADARR_ENABLED) { if (this.config.RADARR_ENABLED) {
await this.pipeline.autoRequest(recommendations); await this.pipeline.autoRequest(recommendations);
} }
@@ -72,7 +73,7 @@ export class RecommendationsController extends Controller {
return; return;
} }
try { 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); reply.send(recommendations);
} catch (err) { } catch (err) {
reply.code(500).send({ error: String(err) }); reply.code(500).send({ error: String(err) });

View File

@@ -9,10 +9,10 @@ export function scheduleCron(config: AppConfig, pipeline: RecommendationPipeline
cron.schedule(config.CRON_SCHEDULE, async () => { cron.schedule(config.CRON_SCHEDULE, async () => {
logger.info('Cron: running recommendation pipeline'); logger.info('Cron: running recommendation pipeline');
try { try {
const recommendations = await pipeline.runAsync({ const recommendations = await pipeline.runAsync(
count: config.RECOMMEND_COUNT, { count: config.RECOMMEND_COUNT, useLlm: config.LLM_ENABLED },
useLlm: config.LLM_ENABLED, { trigger: 'cron' }
}); );
if (config.RADARR_ENABLED) { if (config.RADARR_ENABLED) {
await pipeline.autoRequest(recommendations); await pipeline.autoRequest(recommendations);
} }

View File

@@ -32,14 +32,23 @@ async function main(): Promise<void> {
const promptService = new PromptService(promptGateway, config.LANGFUSE_PROMPT_CACHE_ENABLED); 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 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 tmdb = new TmdbClient(config.TMDB_API_KEY);
const radarr = config.RADARR_ENABLED 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; : undefined;
const recommenderTool = new MovieRecommenderTool(promptService, llmClient); const recommenderTool = new MovieRecommenderTool(promptService, llmClient, config.LLM_MODEL);
const searchTool = new MovieSearchTool(promptService, llmClient); const searchTool = new MovieSearchTool(promptService, llmClient, config.LLM_MODEL);
const pipeline = new RecommendationPipeline(tracer, logger, { const pipeline = new RecommendationPipeline(tracer, logger, {
jellyfin, jellyfin,
@@ -58,12 +67,19 @@ async function main(): Promise<void> {
radarrEnabled: config.RADARR_ENABLED, radarrEnabled: config.RADARR_ENABLED,
cronEnabled: config.CRON_ENABLED, cronEnabled: config.CRON_ENABLED,
apiEnabled: config.API_ENABLED, apiEnabled: config.API_ENABLED,
langfuse: config.LANGFUSE_BASE_URL,
}, },
'sugarr starting' 'sugarr starting'
); );
scheduleCron(config, pipeline, logger); 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) { if (config.API_ENABLED) {
const app = buildServer({ const app = buildServer({
secretKey: config.SECRET_KEY, secretKey: config.SECRET_KEY,

View File

@@ -1,3 +1,4 @@
import { randomUUID } from 'node:crypto';
import { LlmPipeline } from '../../core/llm/LlmPipeline'; import { LlmPipeline } from '../../core/llm/LlmPipeline';
import type { RunContext } from '../../core/llm/RunContext'; import type { RunContext } from '../../core/llm/RunContext';
import type { JellyfinClient } from '../clients/jellyfin/JellyfinClient'; import type { JellyfinClient } from '../clients/jellyfin/JellyfinClient';
@@ -41,12 +42,21 @@ export class RecommendationPipeline extends LlmPipeline<RecommendationRequest, R
const watched = useFavoritesAsSignal ? [] : await this.options.jellyfin.getRecentlyWatched(); const watched = useFavoritesAsSignal ? [] : await this.options.jellyfin.getRecentlyWatched();
const signal = useFavoritesAsSignal ? favorites : watched; const signal = useFavoritesAsSignal ? favorites : watched;
const library = await this.options.jellyfin.getAllMovies(); let library: Movie[] = [];
try {
library = await this.options.jellyfin.getAllMovies();
} catch (err) {
ctx.logger.warn({ err }, 'Could not fetch library; exclusion may be incomplete');
}
const exclude = new Set<number>(toTmdbIds(library)); const exclude = new Set<number>(toTmdbIds(library));
if (this.options.radarr) { if (this.options.radarr) {
const existing = await this.options.radarr.getExisting(); try {
for (const m of existing) exclude.add(m.tmdbId); const existing = await this.options.radarr.getExisting();
for (const m of existing) exclude.add(m.tmdbId);
} catch (err) {
ctx.logger.warn({ err }, 'Could not fetch Radarr library; exclusion may be incomplete');
}
} }
const recommendations: Recommendation[] = []; const recommendations: Recommendation[] = [];
@@ -70,7 +80,13 @@ export class RecommendationPipeline extends LlmPipeline<RecommendationRequest, R
for (const suggestion of suggestions) { for (const suggestion of suggestions) {
if (recommendations.length >= input.count) break; if (recommendations.length >= input.count) break;
const resolved = await this.options.tmdb.searchMovie(suggestion.title, suggestion.year); let resolved: TMDbMovie | undefined;
try {
resolved = await this.options.tmdb.searchMovie(suggestion.title, suggestion.year);
} catch (err) {
ctx.logger.warn({ err, title: suggestion.title }, 'TMDb search failed, skipping suggestion');
continue;
}
if (!resolved) continue; if (!resolved) continue;
seen.push(toSeenMovie(resolved)); seen.push(toSeenMovie(resolved));
if (exclude.has(resolved.id)) continue; if (exclude.has(resolved.id)) continue;
@@ -99,18 +115,37 @@ export class RecommendationPipeline extends LlmPipeline<RecommendationRequest, R
} }
} }
async searchAsync(query: string, count: number): Promise<Recommendation[]> { async searchAsync(query: string, count: number, trigger?: string): Promise<Recommendation[]> {
const trace = this.tracer.startTrace(`${this.name}-search`); 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 }; const ctx: RunContext = { trace, logger: this.logger };
try { try {
const library = await this.options.jellyfin.getAllMovies(); let library: Movie[] = [];
try {
library = await this.options.jellyfin.getAllMovies();
} catch (err) {
ctx.logger.warn({ err }, 'Could not fetch library; exclusion may be incomplete');
}
const exclude = new Set<number>(toTmdbIds(library)); const exclude = new Set<number>(toTmdbIds(library));
const suggestions = await this.runTool(this.options.searchTool, { query, count }, ctx); const suggestions = await this.runTool(this.options.searchTool, { query, count }, ctx);
const recommendations: Recommendation[] = []; const recommendations: Recommendation[] = [];
for (const suggestion of suggestions) { for (const suggestion of suggestions) {
if (recommendations.length >= count) break; if (recommendations.length >= count) break;
const resolved = await this.options.tmdb.searchMovie(suggestion.title, suggestion.year); let resolved: TMDbMovie | undefined;
try {
resolved = await this.options.tmdb.searchMovie(suggestion.title, suggestion.year);
} catch (err) {
ctx.logger.warn({ err, title: suggestion.title }, 'TMDb search failed, skipping suggestion');
continue;
}
if (!resolved || exclude.has(resolved.id)) continue; if (!resolved || exclude.has(resolved.id)) continue;
exclude.add(resolved.id); exclude.add(resolved.id);
recommendations.push({ recommendations.push({
@@ -133,10 +168,17 @@ export class RecommendationPipeline extends LlmPipeline<RecommendationRequest, R
for (const m of signals) { for (const m of signals) {
const tmdbId = Number(m.tmdbId); const tmdbId = Number(m.tmdbId);
if (!tmdbId) continue; if (!tmdbId) continue;
const [similar, recommended] = await Promise.all([ let similar: TMDbMovie[] = [];
this.options.tmdb.getSimilar(tmdbId), let recommended: TMDbMovie[] = [];
this.options.tmdb.getRecommendations(tmdbId), try {
]); [similar, recommended] = await Promise.all([
this.options.tmdb.getSimilar(tmdbId),
this.options.tmdb.getRecommendations(tmdbId),
]);
} catch (err) {
this.logger.warn({ err, tmdbId }, 'TMDb similar/recommendations failed, skipping signal movie');
continue;
}
for (const tm of [...similar, ...recommended]) { for (const tm of [...similar, ...recommended]) {
if (!exclude.has(tm.id)) { if (!exclude.has(tm.id)) {
freq.set(tm.id, (freq.get(tm.id) ?? 0) + 1); freq.set(tm.id, (freq.get(tm.id) ?? 0) + 1);

View File

@@ -38,7 +38,8 @@ export abstract class ApiClient {
const text = await res.text(); const text = await res.text();
const ok = res.ok || endpoint.acceptableStatuses.includes(res.status); const ok = res.ok || endpoint.acceptableStatuses.includes(res.status);
if (!ok) { if (!ok) {
throw new ApiError(`${endpoint.method} ${url} returned ${res.status}`, res.status, text); const safeUrl = url.split('?')[0];
throw new ApiError(`${endpoint.method} ${safeUrl} returned ${res.status}`, res.status, text);
} }
const json = text.length > 0 ? JSON.parse(text) : undefined; const json = text.length > 0 ? JSON.parse(text) : undefined;

View File

@@ -1,11 +1,11 @@
import { Langfuse } from 'langfuse'; import { LangfuseClient } from '@langfuse/client';
import type { PromptFetcher, RawPrompt } from './PromptGateway'; import type { PromptFetcher, RawPrompt } from './PromptGateway';
export class LangfusePromptGateway implements PromptFetcher { export class LangfusePromptGateway implements PromptFetcher {
private readonly client: Langfuse; private readonly client: LangfuseClient;
constructor(opts: { publicKey: string; secretKey: string; baseUrl: string }) { constructor(opts: { publicKey: string; secretKey: string; baseUrl: string }) {
this.client = new Langfuse({ this.client = new LangfuseClient({
publicKey: opts.publicKey, publicKey: opts.publicKey,
secretKey: opts.secretKey, secretKey: opts.secretKey,
baseUrl: opts.baseUrl, baseUrl: opts.baseUrl,
@@ -13,7 +13,7 @@ export class LangfusePromptGateway implements PromptFetcher {
} }
async fetch(promptName: string): Promise<RawPrompt> { 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 { return {
name: prompt.name, name: prompt.name,
version: prompt.version, version: prompt.version,

View File

@@ -1,33 +1,82 @@
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 { 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 { 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 }) { constructor(opts: { publicKey: string; secretKey: string; baseUrl: string }) {
super(); super();
this.client = new Langfuse({ this.processor = new LangfuseSpanProcessor({
publicKey: opts.publicKey, publicKey: opts.publicKey,
secretKey: opts.secretKey, secretKey: opts.secretKey,
baseUrl: opts.baseUrl, baseUrl: opts.baseUrl,
}); });
this.sdk = new NodeSDK({ spanProcessors: [this.processor] });
this.sdk.start();
} }
startTrace(name: string, metadata?: Record<string, unknown>): TraceHandle { startTrace(name: string, metadata?: Record<string, unknown>): TraceHandle {
const trace = this.client.trace({ name, metadata }); const span: LangfuseSpan = startObservation(name, { metadata });
return { return {
id: trace.id, id: span.traceId,
startGeneration(input: GenerationInput): GenerationHandle { startGeneration(input: GenerationInput): GenerationHandle {
const generation = trace.generation({ const generation: LangfuseGeneration = span.startObservation(
name: input.name, input.name,
model: input.model, { model: input.model, input: input.input, metadata: input.metadata },
input: input.input, { asType: 'generation' }
metadata: input.metadata, );
});
return { return {
end(output: GenerationOutput) { 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,
costDetails: output.usage?.cost !== undefined ? { total: output.usage.cost } : undefined,
metadata: output.metadata,
});
generation.end();
}, },
}; };
}, },
@@ -35,6 +84,14 @@ export class LangfuseTracer extends Tracer {
} }
async flush(): Promise<void> { 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();
} }
} }

View File

@@ -1,4 +1,6 @@
import OpenAI from 'openai'; import OpenAI from 'openai';
import type { ChatCompletionCreateParamsNonStreaming } from 'openai/resources/chat/completions';
import type { CompletionUsage } from 'openai/resources/completions';
export interface ChatMessage { export interface ChatMessage {
role: 'system' | 'user' | 'assistant'; role: 'system' | 'user' | 'assistant';
@@ -13,9 +15,28 @@ export interface ChatOptions {
export interface ChatResult { export interface ChatResult {
content: string; content: string;
usage?: { promptTokens?: number; completionTokens?: number; totalTokens?: number }; usage?: {
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
cachedTokens?: number;
cost?: number;
};
} }
/**
* OpenRouter-specific request/response extensions, not part of OpenAI's
* official schema. `usage.include` asks OpenRouter to report its own real
* per-request cost (requires Usage Accounting enabled on the OpenRouter
* account); other OpenAI-compatible providers generally ignore unknown
* top-level request fields. See:
* https://langfuse.com/integrations/gateways/openrouter
*/
type OpenRouterChatCompletionCreateParams = ChatCompletionCreateParamsNonStreaming & {
usage?: { include: boolean };
};
type OpenRouterCompletionUsage = CompletionUsage & { cost?: number };
export class LlmClient { export class LlmClient {
private readonly client: OpenAI; private readonly client: OpenAI;
@@ -29,20 +50,25 @@ export class LlmClient {
temperature: options.temperature, temperature: options.temperature,
max_tokens: options.maxTokens, max_tokens: options.maxTokens,
messages, messages,
}); usage: { include: true },
} as OpenRouterChatCompletionCreateParams);
const choice = response.choices[0]; const choice = response.choices[0];
if (!choice) { if (!choice) {
throw new Error('LLM returned no choices'); throw new Error('LLM returned no choices');
} }
const usage = response.usage as OpenRouterCompletionUsage | undefined;
return { return {
content: choice.message?.content ?? '', content: choice.message?.content ?? '',
usage: response.usage usage: usage
? { ? {
promptTokens: response.usage.prompt_tokens, promptTokens: usage.prompt_tokens,
completionTokens: response.usage.completion_tokens, completionTokens: usage.completion_tokens,
totalTokens: response.usage.total_tokens, totalTokens: usage.total_tokens,
cachedTokens: usage.prompt_tokens_details?.cached_tokens,
cost: usage.cost,
} }
: undefined, : undefined,
}; };

View File

@@ -1,8 +1,14 @@
import { randomUUID } from 'node:crypto';
import type { Logger } from '../logging/logger'; import type { Logger } from '../logging/logger';
import type { LlmTool } from './LlmTool'; import type { LlmTool } from './LlmTool';
import type { RunContext } from './RunContext'; import type { RunContext } from './RunContext';
import type { Tracer } from './Tracer'; 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> { export abstract class LlmPipeline<TInput, TOutput> {
constructor( constructor(
protected readonly tracer: Tracer, protected readonly tracer: Tracer,
@@ -12,14 +18,20 @@ export abstract class LlmPipeline<TInput, TOutput> {
protected abstract readonly name: string; protected abstract readonly name: string;
protected abstract runInternal(input: TInput, ctx: RunContext): Promise<TOutput>; protected abstract runInternal(input: TInput, ctx: RunContext): Promise<TOutput>;
async runAsync(input: TInput): Promise<TOutput> { async runAsync(input: TInput, options: PipelineRunOptions = {}): Promise<TOutput> {
const trace = this.tracer.startTrace(this.name); const sessionId = randomUUID();
const ctx: RunContext = { trace, logger: this.logger }; return this.tracer.runWithSession(
try { { sessionId, tags: options.trigger ? [options.trigger] : undefined },
return await this.runInternal(input, ctx); async () => {
} finally { const trace = this.tracer.startTrace(this.name, { trigger: options.trigger });
await this.tracer.flush(); const ctx: RunContext = { trace, logger: this.logger };
} try {
return await this.runInternal(input, ctx);
} finally {
await this.tracer.flush();
}
}
);
} }
protected runTool<TToolInput, TToolOutput>( protected runTool<TToolInput, TToolOutput>(

View File

@@ -7,7 +7,8 @@ export abstract class LlmTool<TInput, TOutput> {
constructor( constructor(
protected readonly promptService: PromptService, protected readonly promptService: PromptService,
protected readonly llmClient: LlmClient protected readonly llmClient: LlmClient,
protected readonly defaultModel: string = 'gpt-4o-mini'
) {} ) {}
protected abstract buildVariables(input: TInput, ctx: RunContext): Record<string, unknown>; protected abstract buildVariables(input: TInput, ctx: RunContext): Record<string, unknown>;
@@ -27,7 +28,7 @@ export abstract class LlmTool<TInput, TOutput> {
const variables = this.buildVariables(processedInput, ctx); const variables = this.buildVariables(processedInput, ctx);
const compiled = await this.promptService.getCompiled(this.promptName, variables); const compiled = await this.promptService.getCompiled(this.promptName, variables);
const model = String(compiled.config.model ?? 'gpt-4o-mini'); const model = String(compiled.config.model ?? this.defaultModel);
const temperature = const temperature =
typeof compiled.config.temperature === 'number' ? compiled.config.temperature : undefined; typeof compiled.config.temperature === 'number' ? compiled.config.temperature : undefined;
const maxTokens = const maxTokens =

View File

@@ -7,7 +7,13 @@ export interface GenerationInput {
export interface GenerationOutput { export interface GenerationOutput {
output: unknown; output: unknown;
usage?: { promptTokens?: number; completionTokens?: number; totalTokens?: number }; usage?: {
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
cachedTokens?: number;
cost?: number;
};
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
} }
@@ -20,7 +26,18 @@ export interface TraceHandle {
startGeneration(input: GenerationInput): GenerationHandle; startGeneration(input: GenerationInput): GenerationHandle;
} }
export interface SessionInfo {
sessionId: string;
tags?: string[];
}
export abstract class Tracer { export abstract class Tracer {
abstract startTrace(name: string, metadata?: Record<string, unknown>): TraceHandle; abstract startTrace(name: string, metadata?: Record<string, unknown>): TraceHandle;
abstract flush(): Promise<void>; 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>;
} }

View File

@@ -17,7 +17,7 @@ describe('RecommendationsController', () => {
const res = await app.inject({ method: 'GET', url: '/recommendations' }); const res = await app.inject({ method: 'GET', url: '/recommendations' });
expect(res.statusCode).toBe(200); 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 () => { it('GET /recommendations?count= and force_llm/force_tmdb override config', async () => {
@@ -25,7 +25,21 @@ describe('RecommendationsController', () => {
const app = makeApp({ RECOMMEND_COUNT: 10, LLM_ENABLED: false }, { runAsync } as unknown as RecommendationPipeline); 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' }); 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 () => {
const runAsync = vi.fn().mockResolvedValue([]);
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 }, { trigger: 'api-list' });
await app.inject({ method: 'GET', url: '/recommendations?count=-5' });
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 }, { trigger: 'api-list' });
}); });
it('POST /recommendations/trigger auto-requests via Radarr when enabled', async () => { it('POST /recommendations/trigger auto-requests via Radarr when enabled', async () => {
@@ -61,6 +75,6 @@ describe('RecommendationsController', () => {
payload: { query: 'sci-fi like Blade Runner' }, payload: { query: 'sci-fi like Blade Runner' },
}); });
expect(res.statusCode).toBe(200); 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');
}); });
}); });

View File

@@ -25,6 +25,7 @@ function makeTracer() {
const tracer = { const tracer = {
startTrace: vi.fn().mockReturnValue(trace), startTrace: vi.fn().mockReturnValue(trace),
flush: vi.fn().mockResolvedValue(undefined), flush: vi.fn().mockResolvedValue(undefined),
runWithSession: vi.fn().mockImplementation((_session: unknown, fn: () => unknown) => fn()),
} as unknown as Tracer; } as unknown as Tracer;
return tracer; return tracer;
} }
@@ -171,6 +172,119 @@ describe('RecommendationPipeline.runAsync', () => {
expect(tool.runAsync).not.toHaveBeenCalled(); expect(tool.runAsync).not.toHaveBeenCalled();
expect(result).toEqual([{ title: 'Similar A', year: 2010, tmdbId: 20, reason: 'Recommended by TMDb' }]); expect(result).toEqual([{ title: 'Similar A', year: 2010, tmdbId: 20, reason: 'Recommended by TMDb' }]);
}); });
it('continues with an incomplete exclusion set when fetching the library fails', async () => {
const jellyfin = {
getFavoriteMovies: vi.fn().mockResolvedValue([movie('Dune', 2021, '1')]),
getRecentlyWatched: vi.fn(),
getAllMovies: vi.fn().mockRejectedValue(new Error('jellyfin down')),
} as unknown as JellyfinClient;
const tmdb = {
searchMovie: vi.fn().mockResolvedValue({ id: 3, title: 'Interstellar', year: 2014 } as TMDbMovie),
} as unknown as TmdbClient;
const tool = {
runAsync: vi.fn().mockResolvedValue([{ title: 'Interstellar', year: 2014, reason: 'r' }]),
} as unknown as MovieRecommenderTool;
const searchTool = { runAsync: vi.fn() } as unknown as MovieSearchTool;
const logger = { warn: vi.fn() } as unknown as Logger;
const pipeline = new RecommendationPipeline(makeTracer(), logger, {
jellyfin,
tmdb,
tool,
searchTool,
minRatedMovies: 1,
maxRetry: 0,
});
const result = await pipeline.runAsync({ count: 1, useLlm: true });
expect(result).toEqual([{ title: 'Interstellar', year: 2014, tmdbId: 3, reason: 'r' }]);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Could not fetch library; exclusion may be incomplete'
);
});
it('skips a suggestion whose TMDb resolution fails instead of aborting the run', async () => {
const jellyfin = {
getFavoriteMovies: vi.fn().mockResolvedValue([movie('Dune', 2021, '1')]),
getRecentlyWatched: vi.fn(),
getAllMovies: vi.fn().mockResolvedValue([]),
} as unknown as JellyfinClient;
const tmdb = {
searchMovie: vi
.fn()
.mockRejectedValueOnce(new Error('tmdb down'))
.mockResolvedValueOnce({ id: 3, title: 'Interstellar', year: 2014 } as TMDbMovie),
} as unknown as TmdbClient;
const tool = {
runAsync: vi.fn().mockResolvedValue([
{ title: 'Unresolvable', year: 1999, reason: 'r1' },
{ title: 'Interstellar', year: 2014, reason: 'r2' },
]),
} as unknown as MovieRecommenderTool;
const searchTool = { runAsync: vi.fn() } as unknown as MovieSearchTool;
const logger = { warn: vi.fn() } as unknown as Logger;
const pipeline = new RecommendationPipeline(makeTracer(), logger, {
jellyfin,
tmdb,
tool,
searchTool,
minRatedMovies: 1,
maxRetry: 0,
});
const result = await pipeline.runAsync({ count: 2, useLlm: true });
expect(result).toEqual([{ title: 'Interstellar', year: 2014, tmdbId: 3, reason: 'r2' }]);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error), title: 'Unresolvable' }),
'TMDb search failed, skipping suggestion'
);
});
it('skips a signal movie whose TMDb similar/recommendations lookup fails in the fallback path', async () => {
const jellyfin = {
getFavoriteMovies: vi
.fn()
.mockResolvedValue([movie('Broken', 2000, '1'), movie('Dune', 2021, '2')]),
getRecentlyWatched: vi.fn(),
getAllMovies: vi.fn().mockResolvedValue([]),
} as unknown as JellyfinClient;
const tmdb = {
getSimilar: vi.fn().mockImplementation((id: number) => {
if (id === 1) return Promise.reject(new Error('tmdb down'));
return Promise.resolve([{ id: 20, title: 'Similar A', year: 2010 } as TMDbMovie]);
}),
getRecommendations: vi.fn().mockImplementation((id: number) => {
if (id === 1) return Promise.reject(new Error('tmdb down'));
return Promise.resolve([]);
}),
searchMovie: vi.fn().mockResolvedValue({ id: 20, title: 'Similar A', year: 2010 } as TMDbMovie),
} as unknown as TmdbClient;
const tool = { runAsync: vi.fn() } as unknown as MovieRecommenderTool;
const searchTool = { runAsync: vi.fn() } as unknown as MovieSearchTool;
const logger = { warn: vi.fn() } as unknown as Logger;
const pipeline = new RecommendationPipeline(makeTracer(), logger, {
jellyfin,
tmdb,
tool,
searchTool,
minRatedMovies: 2,
maxRetry: 0,
});
const result = await pipeline.runAsync({ count: 1, useLlm: false });
expect(result).toEqual([{ title: 'Similar A', year: 2010, tmdbId: 20, reason: 'Recommended by TMDb' }]);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error), tmdbId: 1 }),
'TMDb similar/recommendations failed, skipping signal movie'
);
});
}); });
describe('RecommendationPipeline.autoRequest', () => { describe('RecommendationPipeline.autoRequest', () => {
@@ -283,4 +397,34 @@ describe('RecommendationPipeline.searchAsync', () => {
expect(result).toEqual([{ title: 'New Find', year: 2020, tmdbId: 6, reason: 'matches query' }]); expect(result).toEqual([{ title: 'New Find', year: 2020, tmdbId: 6, reason: 'matches query' }]);
expect(searchTool.runAsync).toHaveBeenCalledWith({ query: 'sci-fi like Dune', count: 5 }, expect.anything()); expect(searchTool.runAsync).toHaveBeenCalledWith({ query: 'sci-fi like Dune', count: 5 }, expect.anything());
}); });
it('continues with an empty exclusion set when fetching the library fails', async () => {
const jellyfin = {
getAllMovies: vi.fn().mockRejectedValue(new Error('jellyfin down')),
} as unknown as JellyfinClient;
const tmdb = {
searchMovie: vi.fn().mockResolvedValue({ id: 6, title: 'New Find', year: 2020 } as TMDbMovie),
} as unknown as TmdbClient;
const searchTool = {
runAsync: vi.fn().mockResolvedValue([{ title: 'New Find', year: 2020, reason: 'matches query' }]),
} as unknown as MovieSearchTool;
const logger = { warn: vi.fn() } as unknown as Logger;
const pipeline = new RecommendationPipeline(makeTracer(), logger, {
jellyfin,
tmdb,
tool: {} as MovieRecommenderTool,
searchTool,
minRatedMovies: 1,
maxRetry: 0,
});
const result = await pipeline.searchAsync('sci-fi like Dune', 5);
expect(result).toEqual([{ title: 'New Find', year: 2020, tmdbId: 6, reason: 'matches query' }]);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Could not fetch library; exclusion may be incomplete'
);
});
}); });

View File

@@ -101,4 +101,11 @@ describe('ApiClient', () => {
const client = new TestClient('https://api.example.com'); const client = new TestClient('https://api.example.com');
await expect(client.getThing(1)).rejects.toBeInstanceOf(ApiError); await expect(client.getThing(1)).rejects.toBeInstanceOf(ApiError);
}); });
it('omits the query string (which may carry secrets) from the ApiError message', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 500, text: async () => 'boom' });
const client = new TestClient('https://api.example.com');
await expect(client.getThing(1)).rejects.toThrow('GET https://api.example.com/things/1 returned 500');
});
}); });

View File

@@ -1,20 +1,22 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
const compile = vi.fn().mockReturnValue([{ role: 'user', content: 'hi' }]); 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', () => ({ vi.mock('@langfuse/client', () => ({
Langfuse: vi.fn().mockImplementation(() => ({ getPrompt })), LangfuseClient: vi.fn().mockImplementation(() => ({ prompt: { get: promptGet } })),
})); }));
import { LangfusePromptGateway } from '../../../src/core/langfuse/LangfusePromptGateway'; import { LangfusePromptGateway } from '../../../src/core/langfuse/LangfusePromptGateway';
describe('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 gateway = new LangfusePromptGateway({ publicKey: 'pk', secretKey: 'sk', baseUrl: 'http://lf' });
const raw = await gateway.fetch('greet'); 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.name).toBe('greet');
expect(raw.version).toBe(2); expect(raw.version).toBe(2);
expect(raw.config).toEqual({ model: 'llama3' }); expect(raw.config).toEqual({ model: 'llama3' });

View File

@@ -1,42 +1,148 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
const generationEnd = vi.fn(); const {
const generation = vi.fn().mockReturnValue({ end: generationEnd }); forceFlush,
const trace = vi.fn().mockReturnValue({ id: 'lf-trace-1', generation }); shutdown,
const flushAsync = vi.fn().mockResolvedValue(undefined); 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', () => ({ vi.mock('@langfuse/otel', () => ({
Langfuse: vi.fn().mockImplementation(() => ({ trace, flushAsync })), 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'; import { LangfuseTracer } from '../../../src/core/langfuse/LangfuseTracer';
describe('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 tracer = new LangfuseTracer({ publicKey: 'pk', secretKey: 'sk', baseUrl: 'http://lf' });
const handle = tracer.startTrace('my-pipeline', { foo: 'bar' }); 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'); 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 tracer = new LangfuseTracer({ publicKey: 'pk', secretKey: 'sk', baseUrl: 'http://lf' });
const handle = tracer.startTrace('my-pipeline'); const handle = tracer.startTrace('my-pipeline');
const gen = handle.startGeneration({ name: 'step-1', model: 'llama3', input: ['hi'] }); 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(childStartObservation).toHaveBeenCalledWith(
expect.objectContaining({ name: 'step-1', model: 'llama3', input: ['hi'] }) 'step-1',
expect.objectContaining({ model: 'llama3', input: ['hi'] }),
{ asType: 'generation' }
); );
expect(generationEnd).toHaveBeenCalledWith( expect(generationUpdate).toHaveBeenCalledWith(
expect.objectContaining({ output: 'done', usage: { totalTokens: 5 } }) expect.objectContaining({
output: 'done',
usageDetails: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
costDetails: undefined,
})
);
expect(generationEnd).toHaveBeenCalledTimes(1);
});
it('forwards an explicit cost (e.g. from OpenRouter) as costDetails.total instead of relying on price-list lookup', () => {
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: 'openrouter/auto', input: ['hi'] });
gen.end({
output: 'done',
usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15, cost: 0.00042 },
});
expect(generationUpdate).toHaveBeenCalledWith(
expect.objectContaining({ costDetails: { total: 0.00042 } })
); );
}); });
it('flush() calls flushAsync on the underlying client', async () => { 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('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' }); const tracer = new LangfuseTracer({ publicKey: 'pk', secretKey: 'sk', baseUrl: 'http://lf' });
await tracer.flush(); 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');
}); });
}); });

View File

@@ -29,6 +29,46 @@ describe('LlmClient', () => {
temperature: 0.7, temperature: 0.7,
max_tokens: undefined, max_tokens: undefined,
messages: [{ role: 'user', content: 'hi' }], messages: [{ role: 'user', content: 'hi' }],
usage: { include: true },
});
});
it('extracts OpenRouter cost from the response usage when present', async () => {
createMock.mockResolvedValue({
choices: [{ message: { content: 'hello' } }],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15, cost: 0.00042 },
});
const client = new LlmClient({ baseUrl: 'https://openrouter.ai/api/v1', apiKey: 'key' });
const result = await client.chat([{ role: 'user', content: 'hi' }], { model: 'openrouter/auto' });
expect(result.usage).toEqual({
promptTokens: 10,
completionTokens: 5,
totalTokens: 15,
cost: 0.00042,
});
});
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,
}); });
}); });

View File

@@ -23,24 +23,44 @@ class ThrowingPipeline extends LlmPipeline<void, void> {
function makeTracer(trace: TraceHandle) { function makeTracer(trace: TraceHandle) {
const flush = vi.fn().mockResolvedValue(undefined); const flush = vi.fn().mockResolvedValue(undefined);
const startTrace = vi.fn().mockReturnValue(trace); const startTrace = vi.fn().mockReturnValue(trace);
const tracer = { startTrace, flush } as unknown as Tracer; const runWithSession = vi.fn().mockImplementation((_session: unknown, fn: () => unknown) => fn());
return { tracer, startTrace, flush }; const tracer = { startTrace, flush, runWithSession } as unknown as Tracer;
return { tracer, startTrace, flush, runWithSession };
} }
describe('LlmPipeline', () => { 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 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 logger = {} as unknown as Logger;
const pipeline = new SumPipeline(tracer, logger); const pipeline = new SumPipeline(tracer, logger);
const result = await pipeline.runAsync([1, 2, 3]); const result = await pipeline.runAsync([1, 2, 3]);
expect(result).toBe(6); 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); 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 () => { it('still flushes the trace when runInternal throws', async () => {
const trace = { id: 'trace-2', startGeneration: vi.fn() } as unknown as TraceHandle; const trace = { id: 'trace-2', startGeneration: vi.fn() } as unknown as TraceHandle;
const { tracer, flush } = makeTracer(trace); const { tracer, flush } = makeTracer(trace);

View File

@@ -60,6 +60,44 @@ describe('LlmTool.runAsync', () => {
); );
}); });
it('falls back to the constructor-provided defaultModel when the prompt config omits model', async () => {
const getCompiled = vi
.fn()
.mockResolvedValue({ name: 'greet', version: 1, config: {}, messages: [{ role: 'user', content: 'hi' }] });
const promptService = { getCompiled } as unknown as PromptService;
const chat = vi.fn().mockResolvedValue({ content: 'hi' });
const llmClient = { chat } as unknown as LlmClient;
const { ctx } = makeCtx();
const tool = new GreetTool(promptService, llmClient, 'llama3');
await tool.runAsync({ who: 'alice' }, ctx);
expect(chat).toHaveBeenCalledWith(
[{ role: 'user', content: 'hi' }],
{ model: 'llama3', temperature: undefined, maxTokens: undefined }
);
});
it('falls back to gpt-4o-mini when no defaultModel is given and the prompt config omits model', async () => {
const getCompiled = vi
.fn()
.mockResolvedValue({ name: 'greet', version: 1, config: {}, messages: [{ role: 'user', content: 'hi' }] });
const promptService = { getCompiled } as unknown as PromptService;
const chat = vi.fn().mockResolvedValue({ content: 'hi' });
const llmClient = { chat } as unknown as LlmClient;
const { ctx } = makeCtx();
const tool = new GreetTool(promptService, llmClient);
await tool.runAsync({ who: 'alice' }, ctx);
expect(chat).toHaveBeenCalledWith(
[{ role: 'user', content: 'hi' }],
{ model: 'gpt-4o-mini', temperature: undefined, maxTokens: undefined }
);
});
it('ends the generation with error metadata and rethrows when the LLM call fails', async () => { it('ends the generation with error metadata and rethrows when the LLM call fails', async () => {
const promptService = { const promptService = {
getCompiled: vi.fn().mockResolvedValue({ name: 'greet', version: 1, config: {}, messages: [] }), getCompiled: vi.fn().mockResolvedValue({ name: 'greet', version: 1, config: {}, messages: [] }),